Read tag types with phpstan/phpdoc-parser and export hash notation by sirreal · Pull Request #305 · WordPress/phpdoc-parser · GitHub
Skip to content

Read tag types with phpstan/phpdoc-parser and export hash notation - #305

Draft
sirreal wants to merge 3 commits into
masterfrom
adopt-phpstan-phpdoc-parser
Draft

sirreal wants to merge 3 commits into
masterfrom
adopt-phpstan-phpdoc-parser

Conversation

@sirreal

@sirreal sirreal commented Oct 7, 2026

Copy link
Copy Markdown
Member

Read a tag's type with phpstan/phpdoc-parser instead of phpDocumentor's whitespace split, and export WordPress hash notation as structured data.

reflection-docblock 2.x splits a tag's content on the first whitespace and its type on every |. Today @param array<string, mixed> $l10n The data itself. (from wp_localize_script()) exports:

{ "types": ["\\array<string,"], "variable": "", "content": "mixed&gt; $l10n The data itself." }

With this PR:

{ "types": ["array<string, mixed>"], "variable": "$l10n", "content": "The data itself." }

Types

phpDocumentor still splits the DocBlock into descriptions and tags. For @param, @var, @property*, @return and @throws, TypeParser reads the type at the start of the tag content, followed by the variable and description. @method is unchanged.

The types contract is unchanged: one string per top-level union member (UnionTypeNode->types). Each member keeps its source spelling, with class names resolved against the DocBlock's namespace and use aliases as phpDocumentor resolves them. Resolution now also applies inside generics, shapes, callables, intersections and Foo::CONST_* patterns. Keywords, PHPStan pseudo-types (list, class-string, non-empty-string, int<0, max>, …), shape keys and the DocBlock's own @template names are not prefixed. Members are printed from the original tokens, with resolved class names substituted, not from the AST's string form. A multi-line array shape is printed on one line.

wp_ai_client_prompt()'s $prompt (#268) now exports:

string
\WordPress\AiClient\Messages\DTO\MessagePart
\WordPress\AiClient\Messages\DTO\Message
array
list<string|\WordPress\AiClient\Messages\DTO\MessagePart|array>
list<\WordPress\AiClient\Messages\DTO\Message>
null

If TypeParser throws, or the parsed type is not followed by whitespace or the end of the content ((bool): true, string|null.), the tag is exported as it is today. The same applies when whitespace at the top level separates a type from a following bracket (string <code>, array <int>).

Hash notation

When a typed tag's description is a { … } hash, the tag gets a hash key beside content. content is unchanged byte for byte, so the theme's text parsing keeps working until it reads hash. A @type line ending in { opens a nested hash, closed by a line holding only }.

'hash' => array(
	'content' => 'Text before the first @type line.',
	'items'   => array(
		array(
			'types'    => array( 'array<string, mixed>' ), // Read like a tag's types.
			'variable' => '$meta',                         // As written: `$label`, `$0`, `...$0`, or ''.
			'content'  => 'Description.',
			'hash'     => array( /* Nested, same shape. */ ),
		),
	),
),

@return array $results { is also read. Hashes with unbalanced braces get no hash key. In 7.1.2, the only case is the wp_privacy_personal_data_email_content docblock, which lacks its closing }. Part of #119; rendering from hash is theme work.

Import

The importer stores tags with update_post_meta() and applies no KSES there, so generics and hash are stored as exported. A test covers this. make_args_safe() does run wp_filter_kses(), but only on the template-side wp_parser_get_arguments filter. Namespace separators are still unslashed on import (#287).

Corpus diff (WordPress 7.1.2)

Local run of tools/export-corpus.php on master and on this branch: 1,429 hunks. 822 tag entries change type, content or variable; 669 gain hash.

  • 467 types cut at whitespace inside brackets are now whole. Their descriptions lose the leaked tail, and 145 recover their variable.
  • 16 unions inside brackets (list<int|string>, (string|null)[][]) are no longer split.
  • 199 keywords and pseudo-types lose a namespace prefix: \array<string,string>, \SimplePie\?string, \WpOrg\Requests\iterable.
  • 133 change resolution, mostly class names inside generics: \WordPress\AiClient\Builders\list<Message> becomes list<\WordPress\AiClient\Messages\DTO\Message>. This also fixes wrong resolutions: \SimplePie\Cache\InvalidArgumentException&Throwable becomes \Psr\SimpleCache\InvalidArgumentException&\Throwable per the file's use, and \?WP_Error becomes ?\WP_Error.
  • 6 by-reference parameters (Type &$x) recover their variable.
  • 1 literal union, -1|0|1, was exported as \SimplePie\-1, \SimplePie\1, dropping 0; it is now three members.
  • The 669 hashes contain 3,826 items, nested up to 4 levels: 529 on @param, 137 on @return and 3 on @var.

No exported type in the corpus has unbalanced brackets after this change. No variable is lost, and every content change removes a leaked type fragment.

Known limitations:

  • @template names declared on a class are not visible from a method or property DocBlock. SimplePie's Psr7Compatible exports as \SimplePie\HTTP\Psr7Compatible inside a conditional type, as it is resolved on master.
  • The existing global-namespace cleanup in parse_files() strips the leading \ only from a whole bare name. WP_Post stays bare, while list<\WP_Post> and ?\WP_Post keep the backslash.

Relation to other PRs

Supersedes #270, which fixes the type split with a hand-written bracket scanner. This PR uses PHPStan's grammar instead, keeps the current export contract, and leaves theme rendering to wporg-developer.

Unlike #247, this PR does not move the parser onto php-parser 5 or replace reflection-docblock. It adds phpstan/phpdoc-parser ^2.3 (PHP ^7.4 || ^8.0, matching composer.json) and replaces only how a tag's type, variable and description are read.

Rendering generics on developer.wordpress.org also needs WordPress/wporg-developer#598, which escapes types in the theme.

Testing

npm test in wp-env passed on PHP 7.4.33 and 8.4.26 (193 tests, 452 assertions). New tests cover:

  • generics with whitespace, nested unions, array shapes (including multi-line), nullables, intersections, callables, pseudo-types, templates and class-constant patterns
  • class-name resolution inside generics against namespaces and aliases
  • @return generics, by-reference variables and untyped parameters
  • fallback for tags TypeParser rejects
  • the wp_localize_script(), wp_ai_client_prompt() and wp_register_ability() DocBlocks from core
  • nested hashes with $0, ...$0, unnamed entries, continuation lines, generics with whitespace and unbalanced braces
  • a direct data-provider test of the type reader
  • import of generic types and hash into _wp-parser_tags

Fixes #304
Fixes #268
Part of #119

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<string, mixed> $l10n Desc` exports the type
`array<string,`, no variable, and the description `mixed> $l10n Desc`;
`list<string|MessagePart|array>` 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.
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<string, mixed>` 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`.
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.
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Adopt PHPStan for types Nested union types inside generics are split incorrectly

1 participant