From 3f4e818c8386b919479cbc23b4dbc2147d2b5970 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 12:20:03 +0400 Subject: [PATCH 1/3] Read tag types with PHPStan's type parser phpDocumentor's reflection-docblock 2.x splits a tag's content on the first whitespace and its type on every `|`, with no bracket awareness. `@param array $l10n Desc` exports the type `array $l10n Desc`; `list` becomes three types. Generic arguments are never resolved, and PHPDoc keywords such as `list`, `?string` or `class-string<...>` are prefixed with the namespace as if they were class names. Read the type at the start of `@param`, `@var`, `@property*`, `@return` and `@throws` content with phpstan/phpdoc-parser's TypeParser, then the variable and description after it. phpDocumentor still splits the DocBlock into tags and descriptions; only how a tag's type, variable and description are derived changes. The exported `types` array keeps its contract: one string per top-level union member, each printed as written, with class names resolved against the DocBlock's namespace and `use` aliases as phpDocumentor resolved them, now including names inside generics, shapes, callables and class constant patterns. Keywords, PHPStan pseudo-types, shape keys and the DocBlock's own `@template` names are not prefixed. A by-reference `&$name` now yields the variable name. When PHPStan's parser rejects a tag, or the type does not end at whitespace, the tag is exported exactly as before. --- composer.json | 3 +- composer.lock | 49 +++- lib/runner.php | 303 ++++++++++++++++++++++- tests/phpunit/tests/export/docblocks.inc | 138 +++++++++++ tests/phpunit/tests/export/docblocks.php | 246 ++++++++++++++++++ tests/phpunit/tests/export/namespace.inc | 9 + tests/phpunit/tests/export/namespace.php | 17 ++ 7 files changed, 760 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index c5e8e8ef..ae390a0f 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,8 @@ "erusev/parsedown" : "~1.7", "scribu/lib-posts-to-posts": "dev-master@dev", "scribu/scb-framework" : "dev-master@dev", - "psr/log" : "~1.0" + "psr/log" : "~1.0", + "phpstan/phpdoc-parser" : "^2.3" }, "require-dev" : { "phpunit/phpunit": "^9.6", diff --git a/composer.lock b/composer.lock index c6aacf0d..f4e0777c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "495f172180cc0f31d9bad7b5b14f9d40", + "content-hash": "3b8fbf5db9d20e487154f505799e69c4", "packages": [ { "name": "composer/installers", @@ -368,6 +368,53 @@ ], "time": "2026-06-13T20:02:50+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.6", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e", + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.6" + }, + "time": "2026-09-27T20:07:45+00:00" + }, { "name": "psr/log", "version": "1.1.4", diff --git a/lib/runner.php b/lib/runner.php index 1794faef..837faf54 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -525,18 +525,22 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), continue; } + // Types, variable and description come from PHPStan's type parser when + // it reads the tag; otherwise from phpDocumentor, as before. + $parsed_tag = parse_docblock_tag( $tag ); + $tag_data = array( 'name' => $tag->getName(), - 'content' => preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) ), + 'content' => preg_replace( '/[\n\r]+/', ' ', format_description( null !== $parsed_tag ? $parsed_tag['description'] : $tag->getDescription() ) ), ); if ( method_exists( $tag, 'getTypes' ) ) { - $tag_data['types'] = $tag->getTypes(); + $tag_data['types'] = null !== $parsed_tag ? $parsed_tag['types'] : $tag->getTypes(); } if ( method_exists( $tag, 'getLink' ) ) { $tag_data['link'] = $tag->getLink(); } if ( method_exists( $tag, 'getVariableName' ) ) { - $tag_data['variable'] = $tag->getVariableName(); + $tag_data['variable'] = null !== $parsed_tag ? $parsed_tag['variable'] : $tag->getVariableName(); } if ( method_exists( $tag, 'getReference' ) ) { $tag_data['refers'] = $tag->getReference(); @@ -561,6 +565,299 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), return $output; } +/** + * Reads a tag's types, variable and description with PHPStan's type parser. + * + * phpDocumentor 2.x splits a tag's content on the first whitespace and its type + * on every `|`, so `@param array $l10n Desc.` exports the type + * `array $l10n Desc.`. This reads the type + * expression at the start of the content with PHPStan's grammar instead, then + * the variable and description that follow it. + * + * Applies to the tags phpDocumentor gives a type: `@param`, `@var`, `@property`, + * `@property-read` and `@property-write` read a variable after the type; + * `@return` and `@throws` do not. `@method` keeps phpDocumentor's parsing. + * + * @param \phpDocumentor\Reflection\DocBlock\Tag $tag Tag to read. + * + * @return array|null { + * Null when the tag has no type to read or PHPStan's parser does not accept + * it, in which case the caller keeps phpDocumentor's result for the tag. + * + * @type string[] $types One string per top-level union member. + * @type string $variable Variable name, or an empty string. Parameter-like tags only. + * @type string $description The text after the type and variable. + * } + */ +function parse_docblock_tag( $tag ) { + if ( + $tag instanceof \phpDocumentor\Reflection\DocBlock\Tag\MethodTag || + ! ( $tag instanceof \phpDocumentor\Reflection\DocBlock\Tag\ReturnTag ) + ) { + return null; + } + + $has_variable = $tag instanceof \phpDocumentor\Reflection\DocBlock\Tag\ParamTag; + $content = (string) $tag->getContent(); + $docblock = $tag->getDocBlock(); + $context = $docblock ? $docblock->getContext() : null; + $templates = get_docblock_template_names( $docblock ); + + if ( '' === $content ) { + return null; + } + + $types = array(); + $rest = $content; + + // A parameter-like tag may name its variable with no type: `@param $var Desc`. + if ( ! $has_variable || ! preg_match( '/\A&?(?:\.\.\.)?\$/', $content ) ) { + $parsed = parse_docblock_type_expression( $content, $context, $templates ); + if ( null === $parsed ) { + return null; + } + $types = $parsed['types']; + $rest = (string) substr( $content, $parsed['length'] ); + } + + $variable = ''; + if ( $has_variable ) { + // As phpDocumentor reads it: the next whitespace-delimited word, when it + // starts with `$` or `...$`. A by-reference `&` is also accepted here. + $words = preg_split( '/\s+/Su', ltrim( $rest ), 2 ); + if ( preg_match( '/\A&?(?:\.\.\.)?(\$.*)\z/s', $words[0], $match ) ) { + $variable = $match[1]; + $rest = isset( $words[1] ) ? $words[1] : ''; + } + } + + return array( + 'types' => $types, + 'variable' => $variable, + 'description' => trim( $rest ), + ); +} + +/** + * Parses the type expression at the start of a string with PHPStan's type parser. + * + * The type must end at whitespace or at the end of the string, and must not + * attach an opening bracket across whitespace to the type before it outside + * any bracket: in `@return string x`, `` belongs to the + * description. + * + * Each top-level union member is printed as written, with class names resolved + * against the DocBlock's namespace and `use` aliases the way phpDocumentor + * resolves them, including inside generics, shapes and callables. A line break + * inside a type, as in a multi-line array shape, is printed as one space. + * + * @param string $text Text starting with a type. + * @param \phpDocumentor\Reflection\DocBlock\Context|null $context DocBlock context. + * @param true[] $templates Template names declared in the DocBlock, as keys. + * + * @return array|null { + * Null when no type expression is read. + * + * @type string[] $types One string per top-level union member. + * @type int $length Byte length of the type expression in `$text`. + * } + */ +function parse_docblock_type_expression( $text, $context = null, array $templates = array() ) { + static $lexer = null, $type_parser = null; + if ( null === $lexer ) { + $config = new \PHPStan\PhpDocParser\ParserConfig( array( 'indexes' => true ) ); + $lexer = new \PHPStan\PhpDocParser\Lexer\Lexer( $config ); + $type_parser = new \PHPStan\PhpDocParser\Parser\TypeParser( + $config, + new \PHPStan\PhpDocParser\Parser\ConstExprParser( $config ) + ); + } + + try { + $tokens = $lexer->tokenize( $text ); + $node = $type_parser->parse( new \PHPStan\PhpDocParser\Parser\TokenIterator( $tokens ) ); + } catch ( \Throwable $exception ) { + return null; + } + + $start = $node->getAttribute( \PHPStan\PhpDocParser\Ast\Attribute::START_INDEX ); + $end = $node->getAttribute( \PHPStan\PhpDocParser\Ast\Attribute::END_INDEX ); + if ( 0 !== $start || null === $end ) { + return null; + } + + $length = 0; + for ( $i = 0; $i <= $end; $i++ ) { + $length += strlen( $tokens[ $i ][0] ); + } + if ( $length < strlen( $text ) && ! preg_match( '/\A\s/', substr( $text, $length, 1 ) ) ) { + return null; + } + + $whitespace = array( + \PHPStan\PhpDocParser\Lexer\Lexer::TOKEN_HORIZONTAL_WS => true, + \PHPStan\PhpDocParser\Lexer\Lexer::TOKEN_PHPDOC_EOL => true, + ); + $depth = 0; + $previous = ''; + for ( $i = 0; $i <= $end; $i++ ) { + $value = $tokens[ $i ][0]; + if ( isset( $whitespace[ $tokens[ $i ][1] ] ) ) { + // `callable(): (A|B)` and `A | (B|C)[]` start a new type after the + // whitespace; `array ` would attach a bracket to the type before it. + if ( + 0 === $depth && + in_array( $tokens[ $i + 1 ][0], array( '<', '(', '[', '{' ), true ) && + ! in_array( $previous, array( ':', '|', '&' ), true ) + ) { + return null; + } + continue; + } + if ( in_array( $value, array( '<', '(', '[', '{' ), true ) ) { + $depth++; + } elseif ( in_array( $value, array( '>', ')', ']', '}' ), true ) ) { + $depth--; + } + $previous = $value; + } + + $replacements = resolve_docblock_type_names( $node, $tokens, $context, $templates ); + $members = $node instanceof \PHPStan\PhpDocParser\Ast\Type\UnionTypeNode ? $node->types : array( $node ); + $types = array(); + foreach ( $members as $member ) { + $printed = ''; + $to = $member->getAttribute( \PHPStan\PhpDocParser\Ast\Attribute::END_INDEX ); + for ( $i = $member->getAttribute( \PHPStan\PhpDocParser\Ast\Attribute::START_INDEX ); $i <= $to; $i++ ) { + $printed .= isset( $replacements[ $i ] ) ? $replacements[ $i ] : $tokens[ $i ][0]; + } + $types[] = preg_replace( '/\s*[\r\n]\s*/', ' ', $printed ); + } + + return array( + 'types' => $types, + 'length' => $length, + ); +} + +/** + * Resolves the class names in a parsed type to fully qualified names. + * + * Follows phpDocumentor's `Type\Collection::expand()`: a name starting with `\` + * is kept; a name whose first segment is a `use` alias has that segment + * replaced; any other name is prefixed with `\` and the current namespace. + * PHPDoc keywords, PHPStan pseudo-types and the DocBlock's own template names + * are never prefixed, and neither are array and object shape keys. + * + * @param \PHPStan\PhpDocParser\Ast\Type\TypeNode $node Parsed type. + * @param array $tokens Tokens the type was parsed from. + * @param \phpDocumentor\Reflection\DocBlock\Context|null $context DocBlock context. + * @param true[] $templates Template names declared in the DocBlock, as keys. + * + * @return string[] Resolved names keyed by token index. + */ +function resolve_docblock_type_names( $node, array $tokens, $context = null, array $templates = array() ) { + $visitor = new class() extends \PHPStan\PhpDocParser\Ast\AbstractNodeVisitor { + /** @var \PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode[] */ + public $names = array(); + /** @var true[] Shape key nodes, keyed by object ID. */ + public $keys = array(); + + public function enterNode( \PHPStan\PhpDocParser\Ast\Node $node ) { + if ( + ( $node instanceof \PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode || $node instanceof \PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode ) && + is_object( $node->keyName ) + ) { + $this->keys[ spl_object_id( $node->keyName ) ] = true; + } elseif ( $node instanceof \PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode && ! isset( $this->keys[ spl_object_id( $node ) ] ) ) { + $this->names[] = $node; + } elseif ( + $node instanceof \PHPStan\PhpDocParser\Ast\Type\ConstTypeNode && + $node->constExpr instanceof \PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode && + '' !== $node->constExpr->className + ) { + // `Base::TYPE_FEED` starts with the class name's token. + $this->names[] = $node; + } + return null; + } + }; + $traverser = new \PHPStan\PhpDocParser\Ast\NodeTraverser( array( $visitor ) ); + $traverser->traverse( array( $node ) ); + + $namespace = $context ? $context->getNamespace() : ''; + $aliases = $context ? $context->getNamespaceAliases() : array(); + + $replacements = array(); + foreach ( $visitor->names as $identifier ) { + $index = $identifier->getAttribute( \PHPStan\PhpDocParser\Ast\Attribute::START_INDEX ); + $name = $tokens[ $index ][0]; + $known = $identifier instanceof \PHPStan\PhpDocParser\Ast\Type\ConstTypeNode ? $identifier->constExpr->className : $identifier->name; + if ( $name !== $known || isset( $templates[ $name ] ) || ! is_docblock_class_name( $name ) ) { + continue; + } + + $segments = explode( '\\', $name, 2 ); + if ( isset( $aliases[ $segments[0] ] ) ) { + $segments[0] = $aliases[ $segments[0] ]; + $replacements[ $index ] = implode( '\\', $segments ); + } else { + $replacements[ $index ] = '\\' . ( '' !== $namespace ? $namespace . '\\' : '' ) . $name; + } + } + + return $replacements; +} + +/** + * Returns the template type names a DocBlock declares. + * + * `@template T` makes `T` a type variable in that DocBlock, not a class name. + * Templates declared on an enclosing class are not visible here. + * + * @param \phpDocumentor\Reflection\DocBlock|null $docblock DocBlock. + * + * @return true[] Template names as keys. + */ +function get_docblock_template_names( $docblock ) { + $templates = array(); + if ( ! $docblock ) { + return $templates; + } + foreach ( $docblock->getTags() as $tag ) { + if ( + preg_match( '/\A(?:phpstan-|psalm-)?template(?:-covariant|-contravariant)?\z/', $tag->getName() ) && + preg_match( '/\A[A-Za-z_\x80-\xff][\w\x80-\xff]*/', (string) $tag->getContent(), $match ) + ) { + $templates[ $match[0] ] = true; + } + } + return $templates; +} + +/** + * Reports whether a type identifier is a relative class name to resolve. + * + * @param string $name Identifier as written. + * + * @return bool False for fully qualified names, PHPDoc keywords and PHPStan + * pseudo-types such as `list`, `non-empty-string` or `max`. + */ +function is_docblock_class_name( $name ) { + static $keywords = array( + // phpDocumentor's keywords. + 'string', 'int', 'integer', 'bool', 'boolean', 'float', 'double', 'object', 'mixed', 'array', + 'resource', 'void', 'null', 'scalar', 'callback', 'callable', 'false', 'true', 'self', 'static', + // PHPStan's types written without a dash. + 'iterable', 'list', 'never', 'noreturn', 'numeric', 'number', 'empty', 'parent', 'max', 'min', + ); + + // Class names cannot contain `-`; PHPStan types such as `class-string` do. + return '\\' !== $name[0] && + false === strpos( $name, '-' ) && + ! in_array( strtolower( $name ), $keywords, true ); +} + /** * Returns a parser-safe DocBlock with complete fence bodies replaced by blanks. * diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index fbafe08d..74df2039 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -22,6 +22,9 @@ * @since 1.5.0 */ +use WordPress\AiClient\Messages\DTO\Message; +use WordPress\AiClient\Messages\DTO\MessagePart; + /** * This is a function docblock. * @@ -40,6 +43,141 @@ function test_func( $var, $num ) { return true; } +/** + * Localizes a script. + * + * Copied from WordPress core's wp_localize_script(). + * + * @param string $handle Script handle the data will be attached to. + * @param string $object_name Name for the JavaScript object. Passed directly, so it should be qualified JS variable. + * Example: '/[a-zA-Z0-9_]+/'. + * @param array $l10n The data itself. The data can be either a single or multi-dimensional array. + * @return bool True if the script was successfully localized, false otherwise. + */ +function docs_wp_localize_script( $handle, $object_name, $l10n ) { +} + +/** + * Creates a new AI prompt builder using the default provider registry. + * + * Copied from WordPress core's wp_ai_client_prompt(). + * + * @param string|MessagePart|Message|array|list|list|null $prompt Optional. Initial prompt content. + * A string for simple text prompts, + * a MessagePart or Message object for + * structured content, an array for a + * message array shape, or a list of + * parts or messages for multi-turn + * conversations. Default null. + * @return WP_AI_Client_Prompt_Builder The prompt builder instance. + */ +function docs_wp_ai_client_prompt( $prompt = null ) { +} + +/** + * Registers a new ability using the Abilities API. + * + * Copied from WordPress core's wp_register_ability(). + * + * @param string $name The name of the ability. Must be a namespaced string containing + * a prefix, e.g., `my-plugin/my-ability`. Can only contain lowercase + * alphanumeric characters, dashes, and forward slashes. + * @param array $args { + * An associative array of arguments for configuring the ability. + * + * @type string $label Required. The human-readable label for the ability. + * @type string $description Required. A detailed description of what the ability does + * and when it should be used. + * @type string $category Required. The ability category slug this ability belongs to. + * The ability category must be registered via `wp_register_ability_category()` + * before registering the ability. + * @type callable $execute_callback Required. A callback function to execute when the ability is invoked. + * Receives optional mixed input data and must return either a result + * value (any type) or a `WP_Error` object on failure. + * @type callable $permission_callback Required. A callback function to check permissions before execution. + * Receives optional mixed input data (same as `execute_callback`) and + * must return `true`/`false` for simple checks, or `WP_Error` for + * detailed error responses. + * @type array $input_schema Optional. JSON Schema definition for validating the ability's input. + * Must be a valid JSON Schema object defining the structure and + * constraints for input data. Used for automatic validation and + * API documentation. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * Describes the structure of successful return values from + * `execute_callback`. Used for documentation and validation. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations { + * Optional. Semantic annotations describing the ability's behavioral characteristics. + * These annotations are hints for tooling and documentation. + * + * @type bool|null $readonly Optional. If true, the ability does not modify its environment. + * @type bool|null $destructive Optional. If true, the ability may perform destructive updates to its environment. + * If false, the ability performs only additive updates. + * @type bool|null $idempotent Optional. If true, calling the ability repeatedly with the same arguments + * will have no additional effect on its environment. + * } + * @type bool $public Optional. Whether the ability is meant to be available to + * clients such as the REST API, MCP, or AI agents. Seeds + * the default for per-channel flags like `$show_in_rest`. + * Defaults to false. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. + * When true, the ability can be invoked via HTTP requests. + * Default is the value of `$public` when set, false otherwise. + * } + * @type string $ability_class Optional. Fully-qualified custom class name to instantiate + * instead of the default `WP_Ability` class. The custom class + * must extend `WP_Ability`. Useful for advanced customization + * of ability behavior. + * } + * @return WP_Ability|null The registered ability instance on success, `null` on failure. + */ +function docs_wp_register_ability( $name, $args ) { +} + +/** + * Tests type expressions PHPStan's grammar reads and phpDocumentor's does not. + * + * @template T + * + * @param array{label: string, count?: int} $shape An array shape. + * @param ?WP_Post $post A nullable class. + * @param MessagePart&Countable $both An intersection. + * @param iterable> $parts Class names resolved inside generics. + * @param callable(string $a): bool $callback A callable with a signature. + * @param non-empty-string|int<0, max> $pseudo PHPStan pseudo-types. + * @param class-string $class A template type. + * @param MessagePart::TYPE_* $constant A class constant pattern. + * @param array &$by_ref A reference. + * @param $untyped No type. + * @return array Labels keyed by name. + */ +function test_phpstan_types( $shape, $post, $both, $parts, $callback, $pseudo, $class, $constant, &$by_ref, $untyped ) { +} + +/** + * Tests a multi-line array shape return type. + * + * @return array{ + * label: string, + * badge: array{ + * color: string, + * }, + * }|false + */ +function test_multiline_shape_return_type() { +} + +/** + * Tests types PHPStan's parser rejects, which keep phpDocumentor's reading. + * + * @param arrayprop blah + * @return Generator + */ +function test_invalid_type_fallback( $x ) { +} + /** * This is a class docblock. * diff --git a/tests/phpunit/tests/export/docblocks.php b/tests/phpunit/tests/export/docblocks.php index 75cb68b0..258f8af6 100644 --- a/tests/phpunit/tests/export/docblocks.php +++ b/tests/phpunit/tests/export/docblocks.php @@ -230,6 +230,252 @@ public function test_function_docblocks() { ); } + /** + * Test that whitespace inside a generic type does not end the type (wp_localize_script). + */ + public function test_generic_type_with_whitespace() { + + $this->assertFunctionHasDocs( + 'docs_wp_localize_script' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'Script handle the data will be attached to.', + 'types' => array( 'string' ), + 'variable' => '$handle', + ), + array( + 'name' => 'param', + 'content' => 'Name for the JavaScript object. Passed directly, so it should be qualified JS variable.
Example: \'/[a-zA-Z0-9_]+/\'.', + 'types' => array( 'string' ), + 'variable' => '$object_name', + ), + array( + 'name' => 'param', + 'content' => 'The data itself. The data can be either a single or multi-dimensional array.', + 'types' => array( 'array' ), + 'variable' => '$l10n', + ), + array( + 'name' => 'return', + 'content' => 'True if the script was successfully localized, false otherwise.', + 'types' => array( 'bool' ), + ), + ), + ) + ); + } + + /** + * Test that a union nested in a generic stays one type and class names inside it resolve (wp_ai_client_prompt). + */ + public function test_nested_union_inside_generic() { + + $this->assertFunctionHasDocs( + 'docs_wp_ai_client_prompt' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'Optional. Initial prompt content.
A string for simple text prompts, a MessagePart or Message object for structured content, an array for a message array shape, or a list of parts or messages for multi-turn conversations. Default null.', + 'types' => array( + 'string', + '\\WordPress\\AiClient\\Messages\\DTO\\MessagePart', + '\\WordPress\\AiClient\\Messages\\DTO\\Message', + 'array', + 'list', + 'list<\\WordPress\\AiClient\\Messages\\DTO\\Message>', + 'null', + ), + 'variable' => '$prompt', + ), + array( + 'name' => 'return', + 'content' => 'The prompt builder instance.', + 'types' => array( 'WP_AI_Client_Prompt_Builder' ), + ), + ), + ) + ); + } + + /** + * Test type expressions in PHPStan's grammar: shapes, nullables, intersections, callables, pseudo-types, templates, constants and references. + */ + public function test_phpstan_type_expressions() { + + $this->assertFunctionHasDocs( + 'test_phpstan_types' + , array( + 'tags' => array( + array( + 'name' => 'template', + 'content' => 'T', + ), + array( + 'name' => 'param', + 'content' => 'An array shape.', + 'types' => array( 'array{label: string, count?: int}' ), + 'variable' => '$shape', + ), + array( + 'name' => 'param', + 'content' => 'A nullable class.', + 'types' => array( '?\\WP_Post' ), + 'variable' => '$post', + ), + array( + 'name' => 'param', + 'content' => 'An intersection.', + 'types' => array( '\\WordPress\\AiClient\\Messages\\DTO\\MessagePart&\\Countable' ), + 'variable' => '$both', + ), + array( + 'name' => 'param', + 'content' => 'Class names resolved inside generics.', + 'types' => array( 'iterable>' ), + 'variable' => '$parts', + ), + array( + 'name' => 'param', + 'content' => 'A callable with a signature.', + 'types' => array( 'callable(string $a): bool' ), + 'variable' => '$callback', + ), + array( + 'name' => 'param', + 'content' => 'PHPStan pseudo-types.', + 'types' => array( 'non-empty-string', 'int<0, max>' ), + 'variable' => '$pseudo', + ), + array( + 'name' => 'param', + 'content' => 'A template type.', + 'types' => array( 'class-string' ), + 'variable' => '$class', + ), + array( + 'name' => 'param', + 'content' => 'A class constant pattern.', + 'types' => array( '\\WordPress\\AiClient\\Messages\\DTO\\MessagePart::TYPE_*' ), + 'variable' => '$constant', + ), + array( + 'name' => 'param', + 'content' => 'A reference.', + 'types' => array( 'array' ), + 'variable' => '$by_ref', + ), + array( + 'name' => 'param', + 'content' => 'No type.', + 'types' => array(), + 'variable' => '$untyped', + ), + array( + 'name' => 'return', + 'content' => 'Labels keyed by name.', + 'types' => array( 'array' ), + ), + ), + ) + ); + } + + /** + * Test that an array shape written over several lines is one type, printed on one line. + */ + public function test_multiline_array_shape_return_type() { + + $this->assertFunctionHasDocs( + 'test_multiline_shape_return_type' + , array( + 'tags' => array( + array( + 'name' => 'return', + 'content' => '', + 'types' => array( 'array{ label: string, badge: array{ color: string, }, }', 'false' ), + ), + ), + ) + ); + } + + /** + * Test that a tag PHPStan's parser rejects is exported as phpDocumentor reads it. + */ + public function test_invalid_type_keeps_phpdocumentor_reading() { + + $this->assertFunctionHasDocs( + 'test_invalid_type_fallback' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'The arrow->prop blah', + 'types' => array( '\\array '$x', + ), + array( + 'name' => 'return', + 'content' => 'A generator of things>', + 'types' => array( '\\Generatorfind_entity_data_in( $this->export_data, 'functions', 'docs_wp_register_ability' ); + $args = $func['doc']['tags'][1]; + + $this->assertSame( array( 'array' ), $args['types'] ); + $this->assertSame( '$args', $args['variable'] ); + $this->assertStringStartsWith( '{ An associative array of arguments for configuring the ability.
@type string ', $args['content'] ); + } + + /** + * Test the type expression reader directly. + * + * @dataProvider data_type_expressions + * + * @param string $text Text starting with a type. + * @param array|null $expected Expected result. + */ + public function test_parse_docblock_type_expression( $text, $expected ) { + + $this->assertSame( $expected, \WP_Parser\parse_docblock_type_expression( $text ) ); + } + + /** + * Data provider for test_parse_docblock_type_expression(). + * + * @return array[] + */ + public function data_type_expressions() { + + return array( + 'generic with whitespace' => array( 'array $l10n Desc', array( 'types' => array( 'array' ), 'length' => 20 ) ), + 'top-level union' => array( 'int|false', array( 'types' => array( 'int', 'false' ), 'length' => 9 ) ), + 'class name, no context' => array( 'WP_Post|null', array( 'types' => array( '\\WP_Post', 'null' ), 'length' => 12 ) ), + 'shape keys are not class names' => array( 'array{WP_Post: Foo}', array( 'types' => array( 'array{WP_Post: \\Foo}' ), 'length' => 19 ) ), + 'conditional type' => array( "(\$a is 'U' ? int : string) Desc", array( 'types' => array( "(\$a is 'U' ? int : string)" ), 'length' => 26 ) ), + 'parser exception' => array( 'array array( '(mixed depends on context)', null ), + 'no whitespace after the type' => array( 'string|null. Desc', null ), + 'description markup' => array( 'string x', array( 'types' => array( 'string' ), 'length' => 6 ) ), + 'whitespace before a bracket' => array( 'array $x', null ), + 'callable with a grouped return' => array( 'callable(mixed): (bool|WP_Error) $cb', array( 'types' => array( 'callable(mixed): (bool|\\WP_Error)' ), 'length' => 32 ) ), + 'empty string' => array( '', null ), + ); + } + /** * Test that class docs are exported. */ diff --git a/tests/phpunit/tests/export/namespace.inc b/tests/phpunit/tests/export/namespace.inc index 2ee912ea..7ddb5e84 100644 --- a/tests/phpunit/tests/export/namespace.inc +++ b/tests/phpunit/tests/export/namespace.inc @@ -2,4 +2,13 @@ namespace Awesome\Space; +use Other\Place\Thing as Alias; + function ohai() {} + +/** + * Resolves class names against the namespace and its aliases. + * + * @param Local|list|array $value Types. + */ +function resolve_types( $value ) {} diff --git a/tests/phpunit/tests/export/namespace.php b/tests/phpunit/tests/export/namespace.php index dc7834f5..04e1f2a7 100644 --- a/tests/phpunit/tests/export/namespace.php +++ b/tests/phpunit/tests/export/namespace.php @@ -20,4 +20,21 @@ public function test_basic_namespace_support() { $this->assertEquals( $expected, $actual, 'Namespace should be parsed' ); } + + /** + * Test that class names in types, including inside generics, resolve against the namespace and its aliases. + */ + public function test_type_names_resolve_against_namespace() { + $tag = $this->export_data['functions'][1]['doc']['tags'][0]; + + $this->assertSame( + array( + '\\Awesome\\Space\\Local', + 'list<\\Other\\Place\\Thing>', + 'array', + ), + $tag['types'] + ); + $this->assertSame( '$value', $tag['variable'] ); + } } From 76edeafe5b593f282f7af38116173da79d9575a0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 12:20:43 +0400 Subject: [PATCH 2/3] Export hash notation as structured data WordPress documents array keys with its own hash notation: a tag description of the form `{ intro @type Type $key Desc. ... }`, nested when a `@type` line ends in `{`. The parser exports it only as flat text in the tag's `content`, which the theme re-parses at render time by splitting each `@type` line on spaces, so a generic such as `array` breaks there too. When a typed tag's description is a hash, also export a `hash` key: `content` holds the text before the first `@type` line, and `items` one entry per `@type` line with `types` (read like a tag's, falling back to phpDocumentor's reading when PHPStan's parser rejects it), `variable` (as written: `$label`, `$0`, `...$0`, or empty), `content`, and a nested `hash` when the line opens one. A `@return` that names the array before the brace (`@return array $args {`) is read the same way. A hash whose braces do not balance gets no `hash` key. The tag's `content` string is unchanged, so the theme keeps working until it reads `hash`. --- lib/runner.php | 201 +++++++++++++++++++++ tests/phpunit/tests/export/docblocks.inc | 41 +++++ tests/phpunit/tests/export/docblocks.php | 212 +++++++++++++++++++++++ 3 files changed, 454 insertions(+) diff --git a/lib/runner.php b/lib/runner.php index 837faf54..489c818a 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -542,6 +542,17 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), if ( method_exists( $tag, 'getVariableName' ) ) { $tag_data['variable'] = null !== $parsed_tag ? $parsed_tag['variable'] : $tag->getVariableName(); } + if ( $tag instanceof \phpDocumentor\Reflection\DocBlock\Tag\ReturnTag && ! ( $tag instanceof \phpDocumentor\Reflection\DocBlock\Tag\MethodTag ) ) { + // Hash notation, structured; `content` keeps it as flat text. + $hash = export_docblock_hash( + null !== $parsed_tag ? $parsed_tag['description'] : $tag->getDescription(), + $docblock->getContext(), + get_docblock_template_names( $docblock ) + ); + if ( null !== $hash ) { + $tag_data['hash'] = $hash; + } + } if ( method_exists( $tag, 'getReference' ) ) { $tag_data['refers'] = $tag->getReference(); } @@ -858,6 +869,196 @@ function is_docblock_class_name( $name ) { ! in_array( strtolower( $name ), $keywords, true ); } +/** + * Reads WordPress hash notation from a tag description. + * + * A description of the form `{ intro @type Type $key Desc. ... }` documents the + * keys of an array. Each `@type` line names one key; a `@type` line ending in + * `{` opens a nested hash, closed by a line holding only `}`. For example: + * + * @param array $args { + * Optional. Arguments. + * + * @type string $label Label. + * @type array $meta { + * Metadata. + * + * @type bool $public Whether public. + * } + * } + * + * The tag's `content` keeps the hash as flat text; this is a structured copy. + * + * @param string $description Raw tag description. + * @param \phpDocumentor\Reflection\DocBlock\Context|null $context DocBlock context. + * @param true[] $templates Template names declared in the DocBlock, as keys. + * + * @return array|null { + * Null unless the description is a hash with at least one `@type` line and + * balanced braces. + * + * @type string $content Text before the first `@type` line. + * @type array $items { + * One entry per `@type` line. + * + * @type string[] $types One string per top-level union member. + * @type string $variable Key name as written, such as `$label`, + * `$0` or `...$0`, or an empty string. + * @type string $content Description. + * @type array $hash Nested hash, in this same shape, if any. + * } + * } + */ +function export_docblock_hash( $description, $context = null, array $templates = array() ) { + $description = trim( $description ); + + // A few `@return` tags name the returned array first: `@return array $args {`. + $description = preg_replace( '/\A(?:\.\.\.)?\$\w+\s+(?=\{)/', '', $description ); + if ( '{' !== substr( $description, 0, 1 ) || '}' !== substr( $description, -1 ) ) { + return null; + } + + $lines = explode( "\n", substr( $description, 1, -1 ) ); + $index = 0; + $hash = parse_docblock_hash_lines( $lines, $index, false, $context, $templates ); + if ( null === $hash || empty( $hash['items'] ) ) { + return null; + } + + return $hash; +} + +/** + * Reads the lines of one hash level, recursing into nested hashes. + * + * @param string[] $lines Hash body lines. + * @param int $index Current line; left on the closing line of a nested hash. + * @param bool $nested Whether a closing `}` line ends this level. + * @param \phpDocumentor\Reflection\DocBlock\Context|null $context DocBlock context. + * @param true[] $templates Template names declared in the DocBlock, as keys. + * + * @return array|null The hash, or null when its braces do not balance. + */ +function parse_docblock_hash_lines( array $lines, &$index, $nested, $context, array $templates ) { + $intro = array(); + $items = array(); + $current = null; + $count = count( $lines ); + + for ( ; $index < $count; $index++ ) { + $line = trim( $lines[ $index ] ); + + if ( preg_match( '/\A\}[,;.]?\z/', $line ) ) { + if ( ! $nested ) { + return null; + } + return finish_docblock_hash( $intro, $items ); + } + + if ( preg_match( '/\A@type(?:\s+(.*))?\z/s', $line, $match ) ) { + $item = parse_docblock_hash_item( isset( $match[1] ) ? $match[1] : '', $context, $templates ); + if ( $item['opens'] ) { + $index++; + $item['hash'] = parse_docblock_hash_lines( $lines, $index, true, $context, $templates ); + if ( null === $item['hash'] ) { + return null; + } + } + unset( $item['opens'] ); + $items[] = $item; + $current = count( $items ) - 1; + continue; + } + + if ( null === $current ) { + $intro[] = $line; + } else { + $items[ $current ]['lines'][] = $line; + } + } + + return $nested ? null : finish_docblock_hash( $intro, $items ); +} + +/** + * Reads the type, key name and first description line of a `@type` line. + * + * @param string $value Text after `@type`. + * @param \phpDocumentor\Reflection\DocBlock\Context|null $context DocBlock context. + * @param true[] $templates Template names declared in the DocBlock, as keys. + * + * @return array Item with `types`, `variable`, description `lines`, and whether it `opens` a nested hash. + */ +function parse_docblock_hash_item( $value, $context, array $templates ) { + $types = array(); + $rest = $value; + + if ( '' !== $value && ! preg_match( '/\A(?:\.\.\.)?\$/', $value ) ) { + $parsed = parse_docblock_type_expression( $value, $context, $templates ); + if ( null !== $parsed ) { + $types = $parsed['types']; + $rest = (string) substr( $value, $parsed['length'] ); + } else { + // The type as phpDocumentor would read a tag's: the first word. + $words = preg_split( '/\s+/Su', $value, 2 ); + $collection = new \phpDocumentor\Reflection\DocBlock\Type\Collection( array( $words[0] ), $context ); + $types = $collection->getArrayCopy(); + $rest = isset( $words[1] ) ? $words[1] : ''; + } + } + + $variable = ''; + $words = preg_split( '/\s+/Su', ltrim( $rest ), 2 ); + if ( preg_match( '/\A(?:\.\.\.)?\$/', $words[0] ) ) { + $variable = $words[0]; + $rest = isset( $words[1] ) ? $words[1] : ''; + } + + $rest = trim( $rest ); + $opens = '{' === substr( $rest, -1 ); + if ( $opens ) { + $rest = rtrim( substr( $rest, 0, -1 ) ); + } + + return array( + 'types' => $types, + 'variable' => $variable, + 'lines' => array( $rest ), + 'opens' => $opens, + ); +} + +/** + * Formats the text of a parsed hash level like a tag's `content`. + * + * @param string[] $intro Lines before the first `@type` line. + * @param array[] $items Parsed items, each with description `lines`. + * + * @return array Hash with `content` and `items`. + */ +function finish_docblock_hash( array $intro, array $items ) { + $format = function ( array $lines ) { + return preg_replace( '/[\n\r]+/', ' ', format_description( trim( implode( "\n", $lines ) ) ) ); + }; + + foreach ( $items as $key => $item ) { + $finished = array( + 'types' => $item['types'], + 'variable' => $item['variable'], + 'content' => $format( $item['lines'] ), + ); + if ( isset( $item['hash'] ) ) { + $finished['hash'] = $item['hash']; + } + $items[ $key ] = $finished; + } + + return array( + 'content' => $format( $intro ), + 'items' => $items, + ); +} + /** * Returns a parser-safe DocBlock with complete fence bodies replaced by blanks. * diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index 74df2039..390bf501 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -178,6 +178,47 @@ function test_multiline_shape_return_type() { function test_invalid_type_fallback( $x ) { } +/** + * Tests hash notation shapes from WordPress core. + * + * @param array $args { + * Optional. Arguments. + * + * @type string $label Label. + * @type array ...$0 { + * Each entry, wrapped onto + * a continuation line. + * + * @type string $0 The first element. + * @type int $1 The second element. + * } + * @type array { + * An entry with no name. + * + * @type bool $flag A flag. + * }, + * } + * @return array|false $results { + * The results, or false. + * + * @type int $ID Post ID. + * } + */ +function test_hash_notation( $args ) { +} + +/** + * Tests hash notation whose braces do not balance. + * + * @param array $args { + * Arguments. + * + * @type string $label Label. + */ +function test_unbalanced_hash_notation( $args ) { +} + /** * This is a class docblock. * diff --git a/tests/phpunit/tests/export/docblocks.php b/tests/phpunit/tests/export/docblocks.php index 258f8af6..cb58311a 100644 --- a/tests/phpunit/tests/export/docblocks.php +++ b/tests/phpunit/tests/export/docblocks.php @@ -440,6 +440,218 @@ public function test_generic_type_before_hash() { $this->assertStringStartsWith( '{ An associative array of arguments for configuring the ability.
@type string ', $args['content'] ); } + /** + * Test that hash notation is exported as a structure beside the unchanged `content`. + * + * Covers nested hashes, `$0` and `...$0` names, an entry with no name, a + * description on continuation lines, a generic with whitespace, a `@type` + * PHPStan's parser rejects, a `},` closing line, and a named `@return` hash. + * Each `content` string is what the parser exported before hashes were read. + */ + public function test_hash_notation() { + + $this->assertFunctionHasDocs( + 'test_hash_notation' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => '{ Optional. Arguments.
@type string $label Label.
@type array<int $broken A type PHPStan\'s parser rejects.
@type array<string, int|null> ...$0 { Each entry, wrapped onto a continuation line.
@type string $0 The first element.
@type int $1 The second element.
} @type array { An entry with no name.
@type bool $flag A flag.
}, }', + 'types' => array( 'array' ), + 'variable' => '$args', + 'hash' => array( + 'content' => 'Optional. Arguments.', + 'items' => array( + array( + 'types' => array( 'string' ), + 'variable' => '$label', + 'content' => 'Label.', + ), + array( + 'types' => array( '\\array '$broken', + 'content' => 'A type PHPStan\'s parser rejects.', + ), + array( + 'types' => array( 'array' ), + 'variable' => '...$0', + 'content' => '', + 'hash' => array( + 'content' => 'Each entry, wrapped onto a continuation line.', + 'items' => array( + array( + 'types' => array( 'string' ), + 'variable' => '$0', + 'content' => 'The first element.', + ), + array( + 'types' => array( 'int' ), + 'variable' => '$1', + 'content' => 'The second element.', + ), + ), + ), + ), + array( + 'types' => array( 'array' ), + 'variable' => '', + 'content' => '', + 'hash' => array( + 'content' => 'An entry with no name.', + 'items' => array( + array( + 'types' => array( 'bool' ), + 'variable' => '$flag', + 'content' => 'A flag.', + ), + ), + ), + ), + ), + ), + ), + array( + 'name' => 'return', + 'content' => '$results { The results, or false.
@type int $ID Post ID.
}', + 'types' => array( 'array', 'false' ), + 'hash' => array( + 'content' => 'The results, or false.', + 'items' => array( + array( + 'types' => array( 'int' ), + 'variable' => '$ID', + 'content' => 'Post ID.', + ), + ), + ), + ), + ), + ) + ); + } + + /** + * Test that a hash whose braces do not balance is exported as text only. + */ + public function test_unbalanced_hash_notation() { + + $this->assertFunctionHasDocs( + 'test_unbalanced_hash_notation' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => '{ Arguments.
@type string $label Label.', + 'types' => array( 'array' ), + 'variable' => '$args', + ), + ), + ) + ); + } + + /** + * Test the nested hash of a WordPress core function (wp_register_ability). + */ + public function test_core_hash_notation() { + + $func = $this->find_entity_data_in( $this->export_data, 'functions', 'docs_wp_register_ability' ); + + $this->assertSame( + array( + 'content' => 'An associative array of arguments for configuring the ability.', + 'items' => array( + array( + 'types' => array( 'string' ), + 'variable' => '$label', + 'content' => 'Required. The human-readable label for the ability.', + ), + array( + 'types' => array( 'string' ), + 'variable' => '$description', + 'content' => 'Required. A detailed description of what the ability does and when it should be used.', + ), + array( + 'types' => array( 'string' ), + 'variable' => '$category', + 'content' => 'Required. The ability category slug this ability belongs to.
The ability category must be registered via wp_register_ability_category() before registering the ability.', + ), + array( + 'types' => array( 'callable' ), + 'variable' => '$execute_callback', + 'content' => 'Required. A callback function to execute when the ability is invoked.
Receives optional mixed input data and must return either a result value (any type) or a WP_Error object on failure.', + ), + array( + 'types' => array( 'callable' ), + 'variable' => '$permission_callback', + 'content' => 'Required. A callback function to check permissions before execution.
Receives optional mixed input data (same as execute_callback) and must return true/false for simple checks, or WP_Error for detailed error responses.', + ), + array( + 'types' => array( 'array' ), + 'variable' => '$input_schema', + 'content' => 'Optional. JSON Schema definition for validating the ability\'s input.
Must be a valid JSON Schema object defining the structure and constraints for input data. Used for automatic validation and API documentation.', + ), + array( + 'types' => array( 'array' ), + 'variable' => '$output_schema', + 'content' => 'Optional. JSON Schema definition for the ability\'s output.
Describes the structure of successful return values from execute_callback. Used for documentation and validation.', + ), + array( + 'types' => array( 'array' ), + 'variable' => '$meta', + 'content' => '', + 'hash' => array( + 'content' => 'Optional. Additional metadata for the ability.', + 'items' => array( + array( + 'types' => array( 'array' ), + 'variable' => '$annotations', + 'content' => '', + 'hash' => array( + 'content' => 'Optional. Semantic annotations describing the ability\'s behavioral characteristics.
These annotations are hints for tooling and documentation.', + 'items' => array( + array( + 'types' => array( 'bool', 'null' ), + 'variable' => '$readonly', + 'content' => 'Optional. If true, the ability does not modify its environment.', + ), + array( + 'types' => array( 'bool', 'null' ), + 'variable' => '$destructive', + 'content' => 'Optional. If true, the ability may perform destructive updates to its environment.
If false, the ability performs only additive updates.', + ), + array( + 'types' => array( 'bool', 'null' ), + 'variable' => '$idempotent', + 'content' => 'Optional. If true, calling the ability repeatedly with the same arguments will have no additional effect on its environment.', + ), + ), + ), + ), + array( + 'types' => array( 'bool' ), + 'variable' => '$public', + 'content' => 'Optional. Whether the ability is meant to be available to clients such as the REST API, MCP, or AI agents. Seeds the default for per-channel flags like $show_in_rest.
Defaults to false.', + ), + array( + 'types' => array( 'bool' ), + 'variable' => '$show_in_rest', + 'content' => 'Optional. Whether to expose this ability in the REST API.
When true, the ability can be invoked via HTTP requests.
Default is the value of $public when set, false otherwise.', + ), + ), + ), + ), + array( + 'types' => array( 'string' ), + 'variable' => '$ability_class', + 'content' => 'Optional. Fully-qualified custom class name to instantiate instead of the default WP_Ability class. The custom class must extend WP_Ability. Useful for advanced customization of ability behavior.', + ), + ), + ), + $func['doc']['tags'][1]['hash'] + ); + } + /** * Test the type expression reader directly. * From 80a5fc7b683adbbb04b7958fef01bc7f611aec34 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 12:21:09 +0400 Subject: [PATCH 3/3] Test that tag types and hashes survive import Generic types now reach the export intact, and typed tags may carry a `hash` key. The importer stores tags in `_wp-parser_tags` with update_post_meta(), which applies no KSES, so `<...>` in types and the nested `hash` structure are stored as exported and need no importer change. `make_args_safe()`, which does run wp_filter_kses(), is attached to the `wp_parser_get_arguments` filter in the plugin's template functions, not to import. Namespace separators are a separate case: update_post_meta() unslashes them, which #287 addresses. --- tests/phpunit/tests/import/file.php | 55 +++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/phpunit/tests/import/file.php b/tests/phpunit/tests/import/file.php index 2b558909..59f396e4 100644 --- a/tests/phpunit/tests/import/file.php +++ b/tests/phpunit/tests/import/file.php @@ -213,4 +213,59 @@ public function test_function_snippet_metadata_preserves_backslashes() { $this->assertEquals( $snippets, get_post_meta( $post->ID, '_wp-parser_code_snippets', true ) ); $this->assertEquals( $setup_blueprints, get_post_meta( $post->ID, '_wp-parser_setup_blueprints', true ) ); } + + /** + * Test that generic types and hash notation are stored in tag meta as exported. + * + * Import stores tags with update_post_meta(), which unslashes but applies no + * KSES, so `<...>` in types is preserved. Namespace separators are a separate + * problem: unslashing removes them. + */ + public function test_function_tag_types_and_hash_imported() { + + $posts = get_posts( + array( 'post_type' => $this->importer->post_type_function ) + ); + $post = $posts[0]; + + $function_data = $this->export_data['functions'][0]; + $tags = array( + array( + 'name' => 'param', + 'content' => 'The data.', + 'types' => array( 'array', 'list', 'null' ), + 'variable' => '$var', + 'hash' => array( + 'content' => 'Arguments.', + 'items' => array( + array( + 'types' => array( 'array' ), + 'variable' => '$meta', + 'content' => '', + 'hash' => array( + 'content' => '', + 'items' => array( + array( + 'types' => array( 'array{label: string, count?: int}' ), + 'variable' => '$0', + 'content' => 'A shape.', + ), + ), + ), + ), + ), + ), + ), + array( + 'name' => 'return', + 'content' => 'Labels.', + 'types' => array( 'array' ), + ), + ); + $function_data['doc']['tags'] = $tags; + + $this->importer->import_function( $function_data ); + + $this->assertSame( $tags, get_post_meta( $post->ID, '_wp-parser_tags', true ) ); + } }