From 7fb6c93bc88d5387fb25c2b7911c80247b87f077 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 24 Jul 2026 19:25:42 +0200 Subject: [PATCH 1/8] Parse nested unions inside generic types --- lib/runner.php | 137 ++++++++++++++++++++++- tests/phpunit/tests/export/docblocks.inc | 10 ++ tests/phpunit/tests/export/docblocks.php | 24 ++++ 3 files changed, 170 insertions(+), 1 deletion(-) diff --git a/lib/runner.php b/lib/runner.php index 70e77d9..5dd52a3 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -6,6 +6,9 @@ use phpDocumentor\Reflection\ClassReflector; use phpDocumentor\Reflection\ClassReflector\MethodReflector; use phpDocumentor\Reflection\ClassReflector\PropertyReflector; +use phpDocumentor\Reflection\DocBlock\Context; +use phpDocumentor\Reflection\DocBlock\Tag\MethodTag; +use phpDocumentor\Reflection\DocBlock\Type\Collection; use phpDocumentor\Reflection\FunctionReflector; use phpDocumentor\Reflection\FunctionReflector\ArgumentReflector; use phpDocumentor\Reflection\ReflectionAbstract; @@ -233,6 +236,138 @@ function ( $matches ) use ( $replacement_string ) { return $text; } +/** + * Splits a type expression on a delimiter outside of brackets. + * + * @param string $type Type expression. + * @param string $delimiter Delimiter on which to split. + * + * @return string[] + */ +function split_docblock_type_expression( $type, $delimiter ) { + $closing_brackets = array( + '<' => '>', + '(' => ')', + '[' => ']', + '{' => '}', + ); + $bracket_stack = array(); + $parts = array(); + $part = ''; + $length = strlen( $type ); + + for ( $i = 0; $i < $length; $i++ ) { + $character = $type[ $i ]; + + if ( isset( $closing_brackets[ $character ] ) ) { + $bracket_stack[] = $closing_brackets[ $character ]; + } elseif ( + ! empty( $bracket_stack ) + && $character === $bracket_stack[ count( $bracket_stack ) - 1 ] + ) { + array_pop( $bracket_stack ); + } + + if ( $character === $delimiter && empty( $bracket_stack ) ) { + $parts[] = $part; + $part = ''; + continue; + } + + $part .= $character; + } + + $parts[] = $part; + + return $parts; +} + +/** + * Expands aliases in a type expression without losing nested type syntax. + * + * @param string $type Type expression. + * @param Context|null $context DocBlock namespace and alias context. + * + * @return string + */ +function expand_docblock_type_expression( $type, ?Context $context ) { + $type = trim( $type ); + if ( '' === $type ) { + return ''; + } + + foreach ( array( '|', ',' ) as $delimiter ) { + $parts = split_docblock_type_expression( $type, $delimiter ); + if ( 1 < count( $parts ) ) { + $expanded_parts = array(); + foreach ( $parts as $part ) { + $expanded_part = expand_docblock_type_expression( $part, $context ); + if ( '' !== $expanded_part ) { + $expanded_parts[] = $expanded_part; + } + } + + return implode( $delimiter, $expanded_parts ); + } + } + + if ( '[]' === substr( $type, -2 ) ) { + return expand_docblock_type_expression( substr( $type, 0, -2 ), $context ) . '[]'; + } + + if ( preg_match( '/^([^<]+)<(.*)>$/s', $type, $matches ) ) { + $container = expand_docblock_type_expression( $matches[1], $context ); + $arguments = expand_docblock_type_expression( $matches[2], $context ); + + return $container . '<' . $arguments . '>'; + } + + // `list` is a PHPDoc built-in missing from the legacy dependency's keyword list. + if ( 'list' === strtolower( $type ) ) { + return $type; + } + + $types = new Collection( array( $type ), $context ); + + return isset( $types[0] ) ? $types[0] : ''; +} + +/** + * Returns context-aware types from a type-bearing DocBlock tag. + * + * @param object $tag DocBlock tag. + * @param Context|null $context DocBlock namespace and alias context. + * + * @return string[] + */ +function export_docblock_types( $tag, ?Context $context ) { + // Method tags have a distinct content grammar which may begin with `static`. + if ( $tag instanceof MethodTag ) { + return $tag->getTypes(); + } + + $content = trim( $tag->getContent() ); + if ( '' === $content ) { + return $tag->getTypes(); + } + + $content_parts = preg_split( '/\s+/', $content, 2 ); + $type = $content_parts[0]; + if ( '' === $type || '$' === $type[0] || '...$' === substr( $type, 0, 4 ) ) { + return $tag->getTypes(); + } + + $types = array(); + foreach ( split_docblock_type_expression( $type, '|' ) as $type_part ) { + $expanded_type = expand_docblock_type_expression( $type_part, $context ); + if ( '' !== $expanded_type ) { + $types[] = $expanded_type; + } + } + + return empty( $types ) ? $tag->getTypes() : $types; +} + /** * Exports one reflected DocBlock and its runnable snippet metadata. * @@ -530,7 +665,7 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), 'content' => preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) ), ); if ( method_exists( $tag, 'getTypes' ) ) { - $tag_data['types'] = $tag->getTypes(); + $tag_data['types'] = export_docblock_types( $tag, $docblock->getContext() ); } if ( method_exists( $tag, 'getLink' ) ) { $tag_data['link'] = $tag->getLink(); diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index 2762463..b6af9f3 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -22,6 +22,8 @@ * @since 1.5.0 */ +use WordPress\AiClient\Messages\DTO\MessagePart; + /** * This is a function docblock. * @@ -40,6 +42,14 @@ function test_func( $var, $num ) { return true; } +/** + * Tests a nested union type inside a generic. + * + * @param MessagePart|list|MessagePart[] $prompt A prompt. + */ +function test_nested_union_type( $prompt ) { +} + /** * This is a class docblock. * diff --git a/tests/phpunit/tests/export/docblocks.php b/tests/phpunit/tests/export/docblocks.php index ba73e3d..2fe9086 100644 --- a/tests/phpunit/tests/export/docblocks.php +++ b/tests/phpunit/tests/export/docblocks.php @@ -230,6 +230,30 @@ public function test_function_docblocks() { ); } + /** + * Test that a nested union inside a generic remains one exported type. + */ + public function test_nested_union_inside_generic() { + + $this->assertFunctionHasDocs( + 'test_nested_union_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'A prompt.', + 'types' => array( + '\WordPress\AiClient\Messages\DTO\MessagePart', + 'list', + '\WordPress\AiClient\Messages\DTO\MessagePart[]', + ), + 'variable' => '$prompt', + ), + ), + ) + ); + } + /** * Test that class docs are exported. */ From 466332d2b01440c0107dab7403723a3a2dab7884 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 10 Aug 2026 15:09:15 +0400 Subject: [PATCH 2/8] Add failing regression tests for PR #270 type parsing --- tests/phpunit/tests/export/docblocks.inc | 48 ++++++ tests/phpunit/tests/export/docblocks.php | 125 ++++++++++++++ .../phpunit/tests/export/type-expressions.php | 160 ++++++++++++++++++ .../tests/plugin/humanize-separator.php | 68 ++++++++ 4 files changed, 401 insertions(+) create mode 100644 tests/phpunit/tests/export/type-expressions.php create mode 100644 tests/phpunit/tests/plugin/humanize-separator.php diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index b6af9f3..dd3bdf3 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -50,6 +50,54 @@ function test_func( $var, $num ) { function test_nested_union_type( $prompt ) { } +/** + * Tests a parenthesized union with an array suffix. + * + * @param (int|MessagePart)[] $x Grouped. + */ +function test_grouped_union_type( $x ) { +} + +/** + * Tests a generic type which contains a space after the delimiter. + * + * @param array $map A map. + */ +function test_generic_type_with_space( $map ) { +} + +/** + * Tests a type separated from the variable by a non-breaking space. + * + * @param stringĀ $nb NBSP separated. + */ +function test_nbsp_separated_type( $nb ) { +} + +/** + * Tests an iterable generic type. + * + * @param iterable $it Iterable. + */ +function test_iterable_generic_type( $it ) { +} + +/** + * Tests an integer range return type. + * + * @return int<0,max> Count. + */ +function test_int_range_return_type() { +} + +/** + * Tests an intersection type. + * + * @param MessagePart&Countable $both Intersect. + */ +function test_intersection_type( $both ) { +} + /** * This is a class docblock. * diff --git a/tests/phpunit/tests/export/docblocks.php b/tests/phpunit/tests/export/docblocks.php index 2fe9086..faea7d3 100644 --- a/tests/phpunit/tests/export/docblocks.php +++ b/tests/phpunit/tests/export/docblocks.php @@ -254,6 +254,131 @@ public function test_nested_union_inside_generic() { ); } + /** + * Test that a parenthesized union with an array suffix survives the export. + */ + public function test_grouped_union_type() { + + $this->assertFunctionHasDocs( + 'test_grouped_union_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'Grouped.', + 'types' => array( + '(int|\WordPress\AiClient\Messages\DTO\MessagePart)[]', + ), + 'variable' => '$x', + ), + ), + ) + ); + } + + /** + * Test that a space inside a generic doesn't truncate the type. + */ + public function test_generic_type_with_space() { + + $this->assertFunctionHasDocs( + 'test_generic_type_with_space' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'A map.', + 'types' => array( 'array' ), + 'variable' => '$map', + ), + ), + ) + ); + } + + /** + * Test that a non-breaking space separates the type from the variable. + */ + public function test_nbsp_separated_type() { + + $this->assertFunctionHasDocs( + 'test_nbsp_separated_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'NBSP separated.', + 'types' => array( 'string' ), + 'variable' => '$nb', + ), + ), + ) + ); + } + + /** + * Test that an iterable generic isn't turned into a class name. + */ + public function test_iterable_generic_type() { + + $this->assertFunctionHasDocs( + 'test_iterable_generic_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'Iterable.', + 'types' => array( + 'iterable<\WordPress\AiClient\Messages\DTO\MessagePart>', + ), + 'variable' => '$it', + ), + ), + ) + ); + } + + /** + * Test that an integer range return type keeps both of its bounds. + */ + public function test_int_range_return_type() { + + $this->assertFunctionHasDocs( + 'test_int_range_return_type' + , array( + 'tags' => array( + array( + 'name' => 'return', + 'content' => 'Count.', + 'types' => array( 'int<0,max>' ), + ), + ), + ) + ); + } + + /** + * Test that each member of an intersection type is resolved separately. + */ + public function test_intersection_type() { + + $this->assertFunctionHasDocs( + 'test_intersection_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'Intersect.', + 'types' => array( + '\WordPress\AiClient\Messages\DTO\MessagePart&\Countable', + ), + 'variable' => '$both', + ), + ), + ) + ); + } + /** * Test that class docs are exported. */ diff --git a/tests/phpunit/tests/export/type-expressions.php b/tests/phpunit/tests/export/type-expressions.php new file mode 100644 index 0000000..5f98c50 --- /dev/null +++ b/tests/phpunit/tests/export/type-expressions.php @@ -0,0 +1,160 @@ +assertSame( + $expected + , expand_docblock_type_expression( $type, new Context( '\Ns' ) ) + ); + } + + /** + * Data provider for type expressions expanded within the `\Ns` namespace. + * + * @return array[] The type expression, and its expected expansion. + */ + public function data_namespaced_type_expressions() { + + return array( + 'parenthesized union with an array suffix' => array( + '(int|string)[]' + , '(int|string)[]' + ), + 'array shape' => array( + 'array{a: int|string}' + , 'array{a: int|string}' + ), + 'callable signature' => array( + 'callable(int|string): void' + , 'callable(int|string): void' + ), + 'unbalanced brackets' => array( + 'array array( + 'int<0,max>' + , 'int<0,max>' + ), + 'generic with a leading integer literal' => array( + 'array<0,string>' + , 'array<0,string>' + ), + 'generic with a quoted string literal' => array( + "array" + , "array" + ), + 'iterable generic' => array( + 'iterable' + , 'iterable<\Ns\Foo>' + ), + 'class-string generic' => array( + 'class-string' + , 'class-string<\Ns\Foo>' + ), + 'non-empty-list generic' => array( + 'non-empty-list' + , 'non-empty-list' + ), + 'never keyword' => array( + 'never' + , 'never' + ), + 'generic with a space after the delimiter' => array( + 'array' + , 'array' + ), + 'repeated array suffixes' => array( + 'Foo[][]' + , '\Ns\Foo[][]' + ), + ); + } + + /** + * Test type expressions expanded with an aliased namespace. + * + * @dataProvider data_aliased_type_expressions + * + * @param string $type The type expression to expand. + * @param string $expected The expected expansion. + */ + public function test_aliased_type_expressions( $type, $expected ) { + + $context = new Context( '\Acme', array( 'Bar' => 'Vendor\Bar' ) ); + + $this->assertSame( $expected, expand_docblock_type_expression( $type, $context ) ); + } + + /** + * Data provider for type expressions expanded with an aliased namespace. + * + * @return array[] The type expression, and its expected expansion. + */ + public function data_aliased_type_expressions() { + + return array( + 'intersection' => array( + 'Bar&Foo' + , '\Vendor\Bar&\Acme\Foo' + ), + 'intersection inside a generic' => array( + 'list' + , 'list<\Vendor\Bar&\Acme\Foo>' + ), + ); + } + + /** + * Test that a great many array suffixes are expanded without quadratic blowup. + */ + public function test_repeated_array_suffixes_do_not_blow_up() { + + /* + * The current implementation copies the whole expression at every level of + * recursion, so it needs far more memory than the default limit allows. + * Raise the limit so that this test reports the slowdown rather than + * aborting the whole suite with a fatal error. + */ + $memory_limit = ini_get( 'memory_limit' ); + ini_set( 'memory_limit', '1024M' ); + + $type = 'int' . str_repeat( '[]', 20000 ); + + $start = microtime( true ); + $expanded = expand_docblock_type_expression( $type, new Context( '\Ns' ) ); + $elapsed = microtime( true ) - $start; + + ini_set( 'memory_limit', $memory_limit ); + + $this->assertTrue( $type === $expanded, 'The expanded type expression should be unchanged.' ); + $this->assertLessThan( + 5 + , $elapsed + , 'Expanding repeated array suffixes should not take quadratic time.' + ); + } +} diff --git a/tests/phpunit/tests/plugin/humanize-separator.php b/tests/phpunit/tests/plugin/humanize-separator.php new file mode 100644 index 0000000..32975bc --- /dev/null +++ b/tests/phpunit/tests/plugin/humanize-separator.php @@ -0,0 +1,68 @@ +plugin = new \WP_Parser\Plugin(); + } + + /** + * Test that top-level union separators are humanized. + * + * @dataProvider data_humanized_separators + * + * @param string $type The type to humanize. + * @param string $expected The expected humanized type. + */ + public function test_humanize_separator( $type, $expected ) { + + $this->assertSame( $expected, $this->plugin->humanize_separator( $type ) ); + } + + /** + * Data provider for humanized separators. + * + * @return array[] The type, and its expected humanized form. + */ + public function data_humanized_separators() { + + $separator = ' or '; + + return array( + 'top-level union' => array( + 'string|int' + , 'string' . $separator . 'int' + ), + 'union inside brackets' => array( + 'list' + , 'list' + ), + 'top-level union alongside a bracketed union' => array( + 'Foo|list' + , 'Foo' . $separator . 'list' + ), + ); + } +} From 3d49132b8d3b804b5660c0a2108294354f9660a4 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 10 Aug 2026 15:23:47 +0400 Subject: [PATCH 3/8] Fix type-expression regressions in export pipeline --- lib/runner.php | 259 +++++++++++++++++- .../phpunit/tests/export/type-expressions.php | 11 - 2 files changed, 249 insertions(+), 21 deletions(-) diff --git a/lib/runner.php b/lib/runner.php index 5dd52a3..6c30c60 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -282,6 +282,159 @@ function split_docblock_type_expression( $type, $delimiter ) { return $parts; } +/** + * Splits a DocBlock tag's content into its type expression and the text after it. + * + * A type expression may contain whitespace inside of brackets, as in + * `array`, so the boundary is the first whitespace which isn't + * nested inside brackets rather than simply the first whitespace. Whitespace is + * matched in Unicode mode so that a non-breaking space separates the type from + * the rest of the content, matching how the tag was parsed upstream. + * + * @param string $content Tag content. + * + * @return string[] The type expression, followed by the remaining content. + */ +function split_docblock_tag_content( $content ) { + $closing_brackets = array( + '<' => '>', + '(' => ')', + '[' => ']', + '{' => '}', + ); + $bracket_stack = array(); + $whitespace = array(); + $length = strlen( $content ); + + $matched = preg_match_all( '/\s+/Su', $content, $matches, PREG_OFFSET_CAPTURE ); + if ( false === $matched ) { + // The content isn't valid UTF-8; split it bytewise instead. + return split_docblock_tag_content_on_whitespace( $content, '/\s+/' ); + } + + foreach ( $matches[0] as $match ) { + $whitespace[ $match[1] ] = strlen( $match[0] ); + } + + for ( $i = 0; $i < $length; $i++ ) { + if ( isset( $whitespace[ $i ] ) ) { + if ( empty( $bracket_stack ) ) { + return array( + substr( $content, 0, $i ), + substr( $content, $i + $whitespace[ $i ] ), + ); + } + + // Skip past the rest of the whitespace run, which is inside brackets. + $i += $whitespace[ $i ] - 1; + continue; + } + + $character = $content[ $i ]; + + if ( isset( $closing_brackets[ $character ] ) ) { + $bracket_stack[] = $closing_brackets[ $character ]; + } elseif ( + ! empty( $bracket_stack ) + && $character === $bracket_stack[ count( $bracket_stack ) - 1 ] + ) { + array_pop( $bracket_stack ); + } + } + + if ( ! empty( $bracket_stack ) ) { + // The brackets never balance, so nothing better can be inferred than + // the plain split on the first whitespace which was used before. + return split_docblock_tag_content_on_whitespace( $content, '/\s+/u' ); + } + + return array( $content, '' ); +} + +/** + * Splits a DocBlock tag's content on the first whitespace matched by a pattern. + * + * @param string $content Tag content. + * @param string $pattern Whitespace pattern. + * + * @return string[] The type expression, followed by the remaining content. + */ +function split_docblock_tag_content_on_whitespace( $content, $pattern ) { + $parts = preg_split( $pattern, $content, 2 ); + if ( ! is_array( $parts ) ) { + return array( $content, '' ); + } + + return array( $parts[0], isset( $parts[1] ) ? $parts[1] : '' ); +} + +/** + * Reports whether a type is a plain identifier or fully qualified class name. + * + * A trailing class constant, which may itself be a wildcard such as + * `Base::TYPE_*`, is part of that shape because the legacy dependency resolves + * the class name in front of it. + * + * Anything else, such as an array shape, a callable signature, a literal, a + * hyphenated pseudo-type, or an unbalanced fragment, is beyond the grammar the + * legacy dependency understands and must not be handed to it. + * + * @param string $type Type expression. + * + * @return bool + */ +function is_docblock_type_identifier( $type ) { + $identifier = '[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*'; + + return 1 === preg_match( + '/^\\\\?' . $identifier . '(?:\\\\' . $identifier . ')*(?:::' . $identifier . '\*?)?$/', + $type + ); +} + +/** + * Reports whether a type is a keyword rather than a class name. + * + * The legacy dependency has its own keyword list, but it predates a number of + * PHPDoc built-ins and would resolve them against the DocBlock's namespace. + * + * @param string $type Type expression. + * + * @return bool + */ +function is_docblock_type_keyword( $type ) { + static $keywords = array( + 'array', + 'bool', + 'boolean', + 'callable', + 'callback', + 'double', + 'false', + 'float', + 'int', + 'integer', + 'iterable', + 'list', + 'max', + 'min', + 'mixed', + 'never', + 'null', + 'object', + 'parent', + 'resource', + 'scalar', + 'self', + 'static', + 'string', + 'true', + 'void', + ); + + return in_array( strtolower( $type ), $keywords, true ); +} + /** * Expands aliases in a type expression without losing nested type syntax. * @@ -296,23 +449,39 @@ function expand_docblock_type_expression( $type, ?Context $context ) { return ''; } - foreach ( array( '|', ',' ) as $delimiter ) { + foreach ( array( '|', ',', '&' ) as $delimiter ) { $parts = split_docblock_type_expression( $type, $delimiter ); if ( 1 < count( $parts ) ) { $expanded_parts = array(); foreach ( $parts as $part ) { $expanded_part = expand_docblock_type_expression( $part, $context ); - if ( '' !== $expanded_part ) { - $expanded_parts[] = $expanded_part; - } + + // Never drop a part: one which can't be expanded is kept as written. + $expanded_parts[] = '' === $expanded_part ? trim( $part ) : $expanded_part; } return implode( $delimiter, $expanded_parts ); } } - if ( '[]' === substr( $type, -2 ) ) { - return expand_docblock_type_expression( substr( $type, 0, -2 ), $context ) . '[]'; + /* + * Every trailing array suffix is peeled off at once. Recursing once per + * suffix copies the entire expression at each level, which costs quadratic + * time and linear stack depth for a type such as `int[][][]...`. + */ + $length = strlen( $type ); + $base_length = $length; + while ( + $base_length >= 2 + && '[' === $type[ $base_length - 2 ] + && ']' === $type[ $base_length - 1 ] + ) { + $base_length -= 2; + } + + if ( $base_length < $length ) { + return expand_docblock_type_expression( substr( $type, 0, $base_length ), $context ) + . substr( $type, $base_length ); } if ( preg_match( '/^([^<]+)<(.*)>$/s', $type, $matches ) ) { @@ -322,8 +491,37 @@ function expand_docblock_type_expression( $type, ?Context $context ) { return $container . '<' . $arguments . '>'; } - // `list` is a PHPDoc built-in missing from the legacy dependency's keyword list. - if ( 'list' === strtolower( $type ) ) { + /* + * A group which wraps the whole expression, such as `(int|string)`, holds a + * nested expression. The parentheses have to be scanned to tell that group + * apart from an expression which merely starts and ends with one, such as + * `(int)foo(string)`. + */ + if ( '(' === $type[0] && ')' === $type[ $length - 1 ] ) { + $depth = 0; + for ( $i = 0; $i < $length; $i++ ) { + if ( '(' === $type[ $i ] ) { + ++$depth; + } elseif ( ')' === $type[ $i ] ) { + --$depth; + if ( 0 === $depth ) { + break; + } + } + } + + if ( 0 === $depth && $length - 1 === $i ) { + return '(' . expand_docblock_type_expression( substr( $type, 1, -1 ), $context ) . ')'; + } + } + + // A class constant is a keyword when the class name in front of it is one. + $name = strstr( $type, '::', true ); + if ( false === $name ) { + $name = $type; + } + + if ( ! is_docblock_type_identifier( $type ) || is_docblock_type_keyword( $name ) ) { return $type; } @@ -341,7 +539,13 @@ function expand_docblock_type_expression( $type, ?Context $context ) { * @return string[] */ function export_docblock_types( $tag, ?Context $context ) { - // Method tags have a distinct content grammar which may begin with `static`. + /* + * Method tags have a distinct content grammar which may begin with `static`. + * They are also left alone because the legacy dependency matches their + * return type with `[\w|_\\]+`, which can't match `<` at all: a generic type + * in an `@method` tag is already lost by the time the tag reaches here, so + * there is nothing left for this function to recover. + */ if ( $tag instanceof MethodTag ) { return $tag->getTypes(); } @@ -351,7 +555,7 @@ function export_docblock_types( $tag, ?Context $context ) { return $tag->getTypes(); } - $content_parts = preg_split( '/\s+/', $content, 2 ); + $content_parts = split_docblock_tag_content( $content ); $type = $content_parts[0]; if ( '' === $type || '$' === $type[0] || '...$' === substr( $type, 0, 4 ) ) { return $tag->getTypes(); @@ -690,6 +894,41 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), } } } + + /* + * The legacy dependency splits the type off of a tag's content at the + * first whitespace, so a type expression which contains whitespace, such + * as `array`, also swallows the variable name and the start + * of the description. Only in that case are both re-derived here from + * the bracket-aware split; otherwise the parsed values are left alone. + */ + if ( method_exists( $tag, 'getTypes' ) && ! $tag instanceof MethodTag ) { + list( $type, $remainder ) = split_docblock_tag_content( trim( $tag->getContent() ) ); + + if ( 1 === preg_match( '/\s/Su', $type ) ) { + if ( isset( $tag_data['variable'] ) ) { + $remainder_parts = preg_split( '/\s+/Su', ltrim( $remainder ), 2 ); + $variable = isset( $remainder_parts[0] ) ? $remainder_parts[0] : ''; + + // A variadic parameter is named without its ellipsis. + if ( '...$' === substr( $variable, 0, 4 ) ) { + $variable = substr( $variable, 3 ); + } + + if ( '' !== $variable && '$' === $variable[0] ) { + $tag_data['variable'] = $variable; + $remainder = isset( $remainder_parts[1] ) ? $remainder_parts[1] : ''; + } + } + + $tag_data['content'] = preg_replace( + '/[\n\r]+/', + ' ', + format_description( trim( $remainder ) ) + ); + } + } + $output['tags'][] = $tag_data; } diff --git a/tests/phpunit/tests/export/type-expressions.php b/tests/phpunit/tests/export/type-expressions.php index 5f98c50..0aed745 100644 --- a/tests/phpunit/tests/export/type-expressions.php +++ b/tests/phpunit/tests/export/type-expressions.php @@ -133,23 +133,12 @@ public function data_aliased_type_expressions() { */ public function test_repeated_array_suffixes_do_not_blow_up() { - /* - * The current implementation copies the whole expression at every level of - * recursion, so it needs far more memory than the default limit allows. - * Raise the limit so that this test reports the slowdown rather than - * aborting the whole suite with a fatal error. - */ - $memory_limit = ini_get( 'memory_limit' ); - ini_set( 'memory_limit', '1024M' ); - $type = 'int' . str_repeat( '[]', 20000 ); $start = microtime( true ); $expanded = expand_docblock_type_expression( $type, new Context( '\Ns' ) ); $elapsed = microtime( true ) - $start; - ini_set( 'memory_limit', $memory_limit ); - $this->assertTrue( $type === $expanded, 'The expanded type expression should be unchanged.' ); $this->assertLessThan( 5 From 32100d3f86d881e64de8a9dba6246686285644f6 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 26 Aug 2026 13:41:21 +0400 Subject: [PATCH 4/8] Bracket-aware type separator rendering --- lib/class-plugin.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/class-plugin.php b/lib/class-plugin.php index 5fe7f95..9591f4f 100644 --- a/lib/class-plugin.php +++ b/lib/class-plugin.php @@ -248,11 +248,17 @@ public function sanitize_argument( &$value ) { /** * Replace separators with a more readable version * + * Only the separators between the top-level members of a union are replaced. + * A union nested inside brackets, as in `list`, is part of a + * single type and is left untouched. + * * @param string $type Variable type * * @return string */ public function humanize_separator( $type ) { - return str_replace( '|', '' . _x( ' or ', 'separator', 'wp-parser' ) . '', $type ); + $separator = '' . _x( ' or ', 'separator', 'wp-parser' ) . ''; + + return implode( $separator, split_docblock_type_expression( $type, '|' ) ); } } From 04da395f6bfda78f3bcf093a335bc1785774ed2c Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 10 Aug 2026 19:40:59 +0400 Subject: [PATCH 5/8] Add failing round-2 tests: array filter input, boundary-scanner edges, identifier gaps --- tests/phpunit/tests/export/docblocks.inc | 54 ++++++ tests/phpunit/tests/export/docblocks.php | 154 ++++++++++++++++ tests/phpunit/tests/export/tag-content.php | 172 ++++++++++++++++++ .../phpunit/tests/export/type-expressions.php | 80 ++++++++ tests/phpunit/tests/plugin/args-safe.php | 81 +++++++++ .../tests/plugin/humanize-separator.php | 35 ++++ 6 files changed, 576 insertions(+) create mode 100644 tests/phpunit/tests/export/tag-content.php create mode 100644 tests/phpunit/tests/plugin/args-safe.php diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index dd3bdf3..dd07999 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -98,6 +98,46 @@ function test_int_range_return_type() { function test_intersection_type( $both ) { } +/** + * Tests a quoted string literal type which contains a bracket. + * + * @param 'a with gt + */ +function test_quoted_literal_type( $x ) { +} + +/** + * Tests an unclosed generic type followed by prose. + * + * @param arrayprop blah + */ +function test_unclosed_generic_type( $x ) { +} + +/** + * Tests a generic which is missing a comma and balances at the last byte. + * + * @return Generator + */ +function test_unbalanced_generic_return_type() { +} + +/** + * Tests a callable type which declares a return type. + * + * @param callable(int $a, string $b): bool $cb Desc. + */ +function test_callable_return_type( $cb ) { +} + +/** + * Tests a parameter which is passed by reference. + * + * @param array &$arr By ref. + */ +function test_by_reference_param( &$arr ) { +} + /** * This is a class docblock. * @@ -130,6 +170,20 @@ class Test_Class { */ public $a_string; + /** + * This is a callable property. + * + * @var callable(string): string + */ + public $a_callback; + + /** + * This is a property whose type expression begins with a variable. + * + * @var $map{int, string} Some desc + */ + public $a_shape; + /** * This is a method docblock. * diff --git a/tests/phpunit/tests/export/docblocks.php b/tests/phpunit/tests/export/docblocks.php index faea7d3..4a55b30 100644 --- a/tests/phpunit/tests/export/docblocks.php +++ b/tests/phpunit/tests/export/docblocks.php @@ -379,6 +379,113 @@ public function test_intersection_type() { ); } + /** + * Test that a bracket inside a quoted literal doesn't extend the type. + * + * The `<` is inside a string literal, so it doesn't open a bracket and the + * type ends at the first whitespace after the literal. Without that, the + * unclosed bracket swallows the variable and eats up to the `>` in the prose. + */ + public function test_quoted_literal_type() { + + $this->assertFunctionHasDocs( + 'test_quoted_literal_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'A description > with gt', + 'types' => array( "'a '$x', + ), + ), + ) + ); + } + + /** + * Test that an unclosed generic followed by prose falls back to a plain split. + * + * The whitespace after `int` isn't in a position where a type expression may + * contain whitespace, so the type expression is malformed and nothing better + * can be inferred than the plain split the legacy dependency made. + */ + public function test_unclosed_generic_type() { + + $this->assertFunctionHasDocs( + 'test_unclosed_generic_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'The arrow->prop blah', + 'types' => array( 'array '$x', + ), + ), + ) + ); + } + + /** + * Test that a generic which only balances at the last byte doesn't eat the description. + */ + public function test_unbalanced_generic_return_type() { + + $this->assertFunctionHasDocs( + 'test_unbalanced_generic_return_type' + , array( + 'tags' => array( + array( + 'name' => 'return', + 'content' => 'A generator of things>', + 'types' => array( 'GeneratorassertFunctionHasDocs( + 'test_callable_return_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'Desc.', + 'types' => array( 'callable(int $a, string $b): bool' ), + 'variable' => '$cb', + ), + ), + ) + ); + } + + /** + * Test that a by-reference parameter's name is recovered. + */ + public function test_by_reference_param() { + + $this->assertFunctionHasDocs( + 'test_by_reference_param' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'By ref.', + 'types' => array( 'array' ), + 'variable' => '$arr', + ), + ), + ) + ); + } + /** * Test that class docs are exported. */ @@ -1405,4 +1512,51 @@ private function file_greeting_setup_blueprints() { ), ); } + + /** + * Test that a callable property type keeps its return type. + */ + public function test_callable_property_type() { + + $this->assertPropertyHasDocs( + 'Test_Class' + , '$a_callback' + , array( + 'tags' => array( + array( + 'name' => 'var', + 'content' => '', + 'types' => array( 'callable(string): string' ), + 'variable' => '', + ), + ), + ) + ); + } + + /** + * Test that a tag whose content starts with a variable is exported as before. + * + * The legacy dependency treats the whole first token as the variable name and + * leaves nothing for a type. Nothing can be recovered from that, so the + * export has to match the legacy export byte for byte rather than rewriting + * half of it. + */ + public function test_variable_first_property_type() { + + $this->assertPropertyHasDocs( + 'Test_Class' + , '$a_shape' + , array( + 'tags' => array( + array( + 'name' => 'var', + 'content' => 'string} Some desc', + 'types' => array(), + 'variable' => '$map{int,', + ), + ), + ) + ); + } } diff --git a/tests/phpunit/tests/export/tag-content.php b/tests/phpunit/tests/export/tag-content.php new file mode 100644 index 0000000..d3e91eb --- /dev/null +++ b/tests/phpunit/tests/export/tag-content.php @@ -0,0 +1,172 @@ +assertSame( $expected, split_docblock_tag_content( $content ) ); + } + + /** + * Data provider for tag contents. + * + * @return array[] The tag content, and its expected split. + */ + public function data_tag_contents() { + + return array( + 'quoted literal containing a bracket' => array( + "'a with gt" + , array( "'a with gt' ) + ), + 'unclosed generic followed by prose' => array( + 'arrayprop blah' + , array( 'arrayprop blah' ) + ), + 'generic which balances at the last byte' => array( + 'Generator' + , array( 'Generator' ) + ), + 'callable with a return type' => array( + 'callable(int $a, string $b): bool $cb Desc.' + , array( 'callable(int $a, string $b): bool', '$cb Desc.' ) + ), + 'callable with a return type and nothing after it' => array( + 'callable(string): string' + , array( 'callable(string): string', '' ) + ), + 'generic followed by a by-reference variable' => array( + 'array &$arr By ref.' + , array( 'array', '&$arr By ref.' ) + ), + 'array shape which begins with a variable' => array( + '$map{int, string} Some desc' + , array( '$map{int, string}', 'Some desc' ) + ), + 'array shape' => array( + 'array{a: int, b: string} $s Shape.' + , array( 'array{a: int, b: string}', '$s Shape.' ) + ), + 'generic with a space after the delimiter' => array( + 'array $map A map.' + , array( 'array', '$map A map.' ) + ), + 'plain type' => array( + 'string $var A string value.' + , array( 'string', '$var A string value.' ) + ), + ); + } + + /** + * Test that content which isn't valid UTF-8 is split bytewise. + */ + public function test_split_docblock_tag_content_of_invalid_utf8() { + + $this->assertSame( + array( "array", "\xFF\$var Desc" ) + , split_docblock_tag_content( "array \xFF\$var Desc" ) + ); + } + + /** + * Test that a tag whose content isn't valid UTF-8 is exported as parsed. + * + * Content which isn't valid UTF-8 is split bytewise, so the type expression + * may contain whitespace which only a Unicode-mode match would find. Deciding + * whether to re-derive the variable and the description with a Unicode-mode + * match leaves the two halves of the export disagreeing: the description is + * rewritten from a split which never happened, while the variable isn't. + * + * The legacy dependency can't parse a DocBlock which isn't valid UTF-8 at all + * -- it returns no tags -- so the tag is built directly here. + */ + public function test_export_docblock_of_invalid_utf8_content() { + + $context = new Context( '\Ns' ); + $docblock = new DocBlock( "/**\n * Summary.\n */", $context ); + $tag = new Invalid_UTF8_Param_Tag( 'param', 'array $var Desc', $docblock ); + + $docblock->appendTag( $tag ); + + $exported = export_docblock( new Docblock_Bearing_Element( $docblock ) ); + + $this->assertSame( '$var', $exported['tags'][0]['variable'] ); + $this->assertSame( 'Desc', $exported['tags'][0]['content'] ); + } +} + +/** + * A param tag whose content isn't valid UTF-8. + * + * The content is overridden rather than passed to the constructor because the + * legacy dependency emits a PHP warning while parsing content which isn't valid + * UTF-8. + */ +class Invalid_UTF8_Param_Tag extends ParamTag { + + /** + * @return string + */ + public function getContent() { + + return "array \xFF\$var Desc"; + } +} + +/** + * A stand-in for a reflector, which is all that the exporter needs of one. + */ +class Docblock_Bearing_Element { + + /** + * @var DocBlock + */ + protected $docblock; + + /** + * @param DocBlock $docblock The DocBlock to export. + */ + public function __construct( $docblock ) { + + $this->docblock = $docblock; + } + + /** + * @return DocBlock + */ + public function getDocBlock() { + + return $this->docblock; + } +} diff --git a/tests/phpunit/tests/export/type-expressions.php b/tests/phpunit/tests/export/type-expressions.php index 0aed745..5906cb6 100644 --- a/tests/phpunit/tests/export/type-expressions.php +++ b/tests/phpunit/tests/export/type-expressions.php @@ -59,6 +59,54 @@ public function data_namespaced_type_expressions() { 'int<0,max>' , 'int<0,max>' ), + 'integer range with a lower bound keyword' => array( + 'int' + , 'int' + ), + 'max keyword' => array( + 'max' + , 'max' + ), + 'min keyword' => array( + 'min' + , 'min' + ), + 'class named like the max keyword' => array( + 'Max' + , '\Ns\Max' + ), + 'class named like the min keyword' => array( + 'Min' + , '\Ns\Min' + ), + 'class constant' => array( + 'Base::TYPE_FEED' + , '\Ns\Base::TYPE_FEED' + ), + 'class constant wildcard' => array( + 'Foo::*' + , '\Ns\Foo::*' + ), + 'class constant wildcard on a keyword' => array( + 'self::LOCATOR_*' + , 'self::LOCATOR_*' + ), + 'nullable class name' => array( + '?Foo' + , '?\Ns\Foo' + ), + 'nullable class name with an array suffix' => array( + '?Foo[]' + , '?\Ns\Foo[]' + ), + 'nullable keyword' => array( + '?string' + , '?string' + ), + 'nullable never keyword' => array( + '?never' + , '?never' + ), 'generic with a leading integer literal' => array( 'array<0,string>' , 'array<0,string>' @@ -125,6 +173,10 @@ public function data_aliased_type_expressions() { 'list' , 'list<\Vendor\Bar&\Acme\Foo>' ), + 'nullable alias' => array( + '?Bar' + , '?\Vendor\Bar' + ), ); } @@ -146,4 +198,32 @@ public function test_repeated_array_suffixes_do_not_blow_up() { , 'Expanding repeated array suffixes should not take quadratic time.' ); } + + /** + * Test that deeply nested generics are expanded without quadratic blowup. + * + * Each level of nesting rescans and copies the whole remaining expression, so + * the cost grows with the square of the length of the expression rather than + * with its length. + * + * The depth is chosen so that the expansion currently takes about two seconds + * while staying under a hundred and thirty megabytes, which is the smallest + * memory limit this runs under. + */ + public function test_deeply_nested_generics_do_not_blow_up() { + + $depth = 2200; + $type = str_repeat( 'array<', $depth ) . 'int' . str_repeat( '>', $depth ); + + $start = microtime( true ); + $expanded = expand_docblock_type_expression( $type, new Context( '\Ns' ) ); + $elapsed = microtime( true ) - $start; + + $this->assertTrue( $type === $expanded, 'The expanded type expression should be unchanged.' ); + $this->assertLessThan( + 1 + , $elapsed + , 'Expanding deeply nested generics should not take quadratic time.' + ); + } } diff --git a/tests/phpunit/tests/plugin/args-safe.php b/tests/phpunit/tests/plugin/args-safe.php new file mode 100644 index 0000000..7b0ea49 --- /dev/null +++ b/tests/phpunit/tests/plugin/args-safe.php @@ -0,0 +1,81 @@ + '$prompt', + 'default' => null, + 'type' => '\WordPress\AiClient\Messages\DTO\MessagePart', + ), + array( + 'name' => '$list', + 'default' => null, + 'type' => 'list', + ), + array( + 'name' => '$evil', + 'default' => null, + 'type' => '', + ), + ) + ); + } + + /** + * Test that a fully qualified class name keeps its namespace separators. + */ + public function test_namespace_separators_survive() { + + $arguments = $this->filter_arguments(); + + $this->assertSame( + '\WordPress\AiClient\Messages\DTO\MessagePart' + , $arguments[0]['type'] + ); + } + + /** + * Test that a generic type keeps its brackets. + */ + public function test_generic_type_survives() { + + $arguments = $this->filter_arguments(); + + $this->assertSame( 'list', $arguments[1]['type'] ); + } + + /** + * Test that markup in a type isn't passed through as markup. + */ + public function test_markup_in_a_type_is_not_passed_through() { + + $arguments = $this->filter_arguments(); + + $this->assertStringNotContainsString( '