diff --git a/lib/runner.php b/lib/runner.php index 4250b23a..22ce5821 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -5,6 +5,9 @@ use phpDocumentor\Reflection\BaseReflector; 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; @@ -221,6 +224,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; +} + /** * @param BaseReflector|ReflectionAbstract $element * @@ -248,7 +383,7 @@ function export_docblock( $element ) { '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 05686f51..29acd460 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -10,6 +10,8 @@ * @since 1.5.0 */ +use WordPress\AiClient\Messages\DTO\MessagePart; + /** * This is a function docblock. * @@ -28,6 +30,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 0d49b15d..b59aa638 100644 --- a/tests/phpunit/tests/export/docblocks.php +++ b/tests/phpunit/tests/export/docblocks.php @@ -108,6 +108,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. */