From a938362eafb22bcc1bcd4bfe84d4156e25ae9676 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 19:36:59 +0200 Subject: [PATCH 01/13] Test the docblock mangling --- tests/phpunit/tests/export/docblocks.inc | 10 ++++++++++ tests/phpunit/tests/export/docblocks.php | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index 2762463..bb846ca 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -40,6 +40,16 @@ function test_func( $var, $num ) { return true; } +/** + * Tests special characters in documentation. + * + * ```php + * true === wp_is_valid_utf8( '✏' ); + * false === wp_is_valid_utf8( "just \xC0 test" ); + * ``` + */ +function test_special_characters() {} + /** * This is a class docblock. * diff --git a/tests/phpunit/tests/export/docblocks.php b/tests/phpunit/tests/export/docblocks.php index ba73e3d..3789c30 100644 --- a/tests/phpunit/tests/export/docblocks.php +++ b/tests/phpunit/tests/export/docblocks.php @@ -230,6 +230,19 @@ public function test_function_docblocks() { ); } + /** + * Test that special characters in documentation are preserved. + */ + public function test_special_characters_are_preserved() { + $this->assertFunctionHasDocs( + 'test_special_characters', + array( + 'long_description' => '
true === wp_is_valid_utf8( \'✏\' );' . "\n"
+					. 'false === wp_is_valid_utf8( "just \\xC0 test" );
', + ) + ); + } + /** * Test that class docs are exported. */ From 2eafef10d447472fd01c773b8267f81ad4b721fc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 19:41:45 +0200 Subject: [PATCH 02/13] Stop rewriting documentation --- lib/runner.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/runner.php b/lib/runner.php index 70e77d9..dfda240 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -152,10 +152,9 @@ function parse_files( $files, $root ) { /* * nikic/php-parser in version 3 started adding a namespace prefix * at the start of global names, but this is different than how the - * documentation was previously generated. this removes those prefixes - * by removing a leading reverse solidus (\) when no other reverse - * solidus appears before the end of a sequence of PHP identifier - * characters. + * documentation was previously generated. This removes those prefixes + * only at the start of an output value so embedded reverse soliduses (\) + * in documentation and other source text remain untouched. */ array_walk_recursive( $output, @@ -163,8 +162,8 @@ static function( &$value ) { if ( is_string( $value ) ) { // "\wp_kses()" -> "wp_kses()" $without_global_namespace = preg_replace( - '~(^|\p{Z})\\\\([A-Z_a-z\x80-\xFF][0-9A-Z_a-z\x80-\xFF]*)([:(\p{Z}]|->|$)~', - '$1$2$3', + '~^\\\\([A-Z_a-z\x80-\xFF][0-9A-Z_a-z\x80-\xFF]*)([:(\p{Z}]|->|$)~', + '$1$2', $value, ); From cc03c2e9cb23a3294b2656ae20637d894439a83f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 19:59:43 +0200 Subject: [PATCH 03/13] Test names without eating text --- tests/phpunit/tests/export/docblocks.inc | 5 ++ tests/phpunit/tests/export/docblocks.php | 12 +++++ tests/phpunit/tests/export/global-names.inc | 14 ++++++ tests/phpunit/tests/export/global-names.php | 55 +++++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 tests/phpunit/tests/export/global-names.inc create mode 100644 tests/phpunit/tests/export/global-names.php diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index bb846ca..e691bb3 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -50,6 +50,11 @@ function test_func( $var, $num ) { */ function test_special_characters() {} +/** + * \xC0 starts this description. + */ +function test_leading_escape_sequence() {} + /** * This is a class docblock. * diff --git a/tests/phpunit/tests/export/docblocks.php b/tests/phpunit/tests/export/docblocks.php index 3789c30..dc902f0 100644 --- a/tests/phpunit/tests/export/docblocks.php +++ b/tests/phpunit/tests/export/docblocks.php @@ -243,6 +243,18 @@ public function test_special_characters_are_preserved() { ); } + /** + * Test that a leading escape sequence in documentation is preserved. + */ + public function test_leading_escape_sequence_is_preserved() { + $this->assertFunctionHasDocs( + 'test_leading_escape_sequence', + array( + 'description' => '\\xC0 starts this description.', + ) + ); + } + /** * Test that class docs are exported. */ diff --git a/tests/phpunit/tests/export/global-names.inc b/tests/phpunit/tests/export/global-names.inc new file mode 100644 index 0000000..3dd9998 --- /dev/null +++ b/tests/phpunit/tests/export/global-names.inc @@ -0,0 +1,14 @@ +export_data['functions'][0]; + + $this->assertEquals( + array( 'Global_Alias' => 'Global_Alias_Source' ), + $function['aliases'] + ); + $this->assertEquals( 'Global_Parameter', $function['arguments'][0]['type'] ); + $this->assertEquals( + array( 'Global_Doc_Type' ), + $function['doc']['tags'][0]['types'] + ); + } + + /** + * Test class metadata. + */ + public function test_class_metadata() { + $class = $this->export_data['classes'][0]; + + $this->assertEquals( 'Global_Parent', $class['extends'] ); + $this->assertEquals( array( 'Global_Interface' ), $class['implements'] ); + } + + /** + * Test method-use metadata. + */ + public function test_method_use_metadata() { + $this->assertFileUsesMethod( + array( + 'name' => 'global_method', + 'line' => 14, + 'end_line' => 14, + 'class' => 'Global_Class', + 'static' => true, + ) + ); + } +} From 778aef5de4e14810a04f75969de0b02fca3ec911 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 20:01:43 +0200 Subject: [PATCH 04/13] Test hook names verbatim --- tests/phpunit/tests/export/hooks.inc | 1 + tests/phpunit/tests/export/hooks.php | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/tests/phpunit/tests/export/hooks.inc b/tests/phpunit/tests/export/hooks.inc index 54cf8f8..53a65aa 100644 --- a/tests/phpunit/tests/export/hooks.inc +++ b/tests/phpunit/tests/export/hooks.inc @@ -6,3 +6,4 @@ do_action( $variable . '-action' ); do_action( "another-{$variable}-action" ); do_action( 'hook_' . $object->property . '_pre' ); apply_filters( 'plain_filter', $variable, $filter_context ); +do_action( '\xC0 hook' ); diff --git a/tests/phpunit/tests/export/hooks.php b/tests/phpunit/tests/export/hooks.php index cf9d608..10d4b79 100644 --- a/tests/phpunit/tests/export/hooks.php +++ b/tests/phpunit/tests/export/hooks.php @@ -45,5 +45,9 @@ public function test_hook_names_standardized() { 'arguments.1' => '$filter_context' ) ); + + $this->assertFileContainsHook( + array( 'name' => '\\xC0 hook', 'line' => 9 ) + ); } } From 8de43d1463d479456246cce70ce186c757a45861 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 20:05:21 +0200 Subject: [PATCH 05/13] Test PHP string semantics --- tests/phpunit/tests/export/hooks.inc | 2 ++ tests/phpunit/tests/export/hooks.php | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/tests/phpunit/tests/export/hooks.inc b/tests/phpunit/tests/export/hooks.inc index 53a65aa..88a3736 100644 --- a/tests/phpunit/tests/export/hooks.inc +++ b/tests/phpunit/tests/export/hooks.inc @@ -7,3 +7,5 @@ do_action( "another-{$variable}-action" ); do_action( 'hook_' . $object->property . '_pre' ); apply_filters( 'plain_filter', $variable, $filter_context ); do_action( '\xC0 hook' ); +do_action( "\x09tab" ); +do_action( '\x09tab' ); diff --git a/tests/phpunit/tests/export/hooks.php b/tests/phpunit/tests/export/hooks.php index 10d4b79..0e9fe29 100644 --- a/tests/phpunit/tests/export/hooks.php +++ b/tests/phpunit/tests/export/hooks.php @@ -49,5 +49,13 @@ public function test_hook_names_standardized() { $this->assertFileContainsHook( array( 'name' => '\\xC0 hook', 'line' => 9 ) ); + + $this->assertFileContainsHook( + array( 'name' => "\ttab", 'line' => 10 ) + ); + + $this->assertFileContainsHook( + array( 'name' => '\\x09tab', 'line' => 11 ) + ); } } From 877372b3acdf294b515130def292b1d1b7f4d7bc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 20:07:33 +0200 Subject: [PATCH 06/13] Stop confusing names with text --- lib/class-hook-reflector.php | 5 +++ lib/runner.php | 70 ++++++++++++++++++++---------------- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/lib/class-hook-reflector.php b/lib/class-hook-reflector.php index 6b979b9..2d4b815 100644 --- a/lib/class-hook-reflector.php +++ b/lib/class-hook-reflector.php @@ -13,6 +13,11 @@ class Hook_Reflector extends BaseReflector { * @return string */ public function getName() { + $name = $this->node->args[0]->value; + if ( $name instanceof \PhpParser\Node\Scalar\String_ ) { + return $name->value; + } + $printer = new \PhpParser\PrettyPrinter\Standard(); return $this->cleanupName( $printer->prettyPrintExpr( $this->node->args[0]->value ) ); } diff --git a/lib/runner.php b/lib/runner.php index dfda240..0ce5d16 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -102,7 +102,7 @@ function parse_files( $files, $root ) { $func = array( 'name' => $function->getShortName(), 'namespace' => $function->getNamespace(), - 'aliases' => $function->getNamespaceAliases(), + 'aliases' => strip_global_namespace_prefixes( $function->getNamespaceAliases() ), 'line' => $function->getLineNumber(), 'end_line' => $function->getNode()->getAttribute( 'endLine' ), 'arguments' => export_arguments( $function->getArguments() ), @@ -132,8 +132,8 @@ function parse_files( $files, $root ) { 'end_line' => $class->getNode()->getAttribute( 'endLine' ), 'final' => $class->isFinal(), 'abstract' => $class->isAbstract(), - 'extends' => $class->getParentClass(), - 'implements' => $class->getInterfaces(), + 'extends' => strip_global_namespace_prefix( $class->getParentClass() ), + 'implements' => strip_global_namespace_prefixes( $class->getInterfaces() ), 'properties' => export_properties( $class->getProperties(), $class_setup_blueprints, $path ), 'methods' => export_methods( $class->getMethods(), $class_setup_blueprints, $path ), 'doc' => $class_doc, @@ -149,32 +149,41 @@ function parse_files( $files, $root ) { throw $e; } - /* - * nikic/php-parser in version 3 started adding a namespace prefix - * at the start of global names, but this is different than how the - * documentation was previously generated. This removes those prefixes - * only at the start of an output value so embedded reverse soliduses (\) - * in documentation and other source text remain untouched. - */ - array_walk_recursive( - $output, - static function( &$value ) { - if ( is_string( $value ) ) { - // "\wp_kses()" -> "wp_kses()" - $without_global_namespace = preg_replace( - '~^\\\\([A-Z_a-z\x80-\xFF][0-9A-Z_a-z\x80-\xFF]*)([:(\p{Z}]|->|$)~', - '$1$2', - $value, - ); + return $output; +} - if ( $value !== $without_global_namespace ) { - $value = $without_global_namespace; - } - } - } +/** + * Remove a synthetic leading namespace prefix from a global name. + * + * @param mixed $name Name to normalize. + * + * @return mixed + */ +function strip_global_namespace_prefix( $name ) { + if ( ! is_string( $name ) ) { + return $name; + } + + return preg_replace( + '~^\\\\([A-Z_a-z\x80-\xFF][0-9A-Z_a-z\x80-\xFF]*)([:(\p{Z}]|->|$)~', + '$1$2', + $name ); +} - return $output; +/** + * Remove synthetic leading namespace prefixes from global names. + * + * @param array $names Names to normalize. + * + * @return array + */ +function strip_global_namespace_prefixes( array $names ) { + foreach ( $names as $key => $name ) { + $names[ $key ] = strip_global_namespace_prefix( $name ); + } + + return $names; } /** @@ -529,7 +538,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'] = strip_global_namespace_prefixes( $tag->getTypes() ); } if ( method_exists( $tag, 'getLink' ) ) { $tag_data['link'] = $tag->getLink(); @@ -697,7 +706,7 @@ function export_arguments( array $arguments ) { $output[] = array( 'name' => $argument->getName(), 'default' => $argument->getDefault(), - 'type' => $argument->getType(), + 'type' => strip_global_namespace_prefix( $argument->getType() ), ); } @@ -745,7 +754,7 @@ function export_methods( array $methods, array $inherited_setup_blueprints = arr $method_data = array( 'name' => $method->getShortName(), 'namespace' => $method->getNamespace(), - 'aliases' => $method->getNamespaceAliases(), + 'aliases' => strip_global_namespace_prefixes( $method->getNamespaceAliases() ), 'line' => $method->getLineNumber(), 'end_line' => $method->getNode()->getAttribute( 'endLine' ), 'final' => $method->isFinal(), @@ -1282,7 +1291,7 @@ function export_uses( array $uses ) { case 'methods': $out[ $type ][] = array( 'name' => $name[1], - 'class' => $name[0], + 'class' => strip_global_namespace_prefix( $name[0] ), 'static' => $element->isStatic(), 'line' => $element->getLineNumber(), 'end_line' => $element->getNode()->getAttribute( 'endLine' ), @@ -1291,6 +1300,7 @@ function export_uses( array $uses ) { default: case 'functions': + $name = strip_global_namespace_prefix( $name ); $out[ $type ][] = array( 'name' => $name, 'line' => $element->getLineNumber(), From f2d6745e9e8619ff32612caf1817620241ba663f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 22:41:34 +0200 Subject: [PATCH 07/13] Preserve expression output --- lib/class-hook-reflector.php | 2 +- lib/class-pretty-printer.php | 19 +++++- lib/runner.php | 68 ++++++++++++++++++--- tests/phpunit/tests/export/global-names.inc | 22 ++++++- tests/phpunit/tests/export/global-names.php | 48 ++++++++++++++- 5 files changed, 145 insertions(+), 14 deletions(-) diff --git a/lib/class-hook-reflector.php b/lib/class-hook-reflector.php index 2d4b815..be10164 100644 --- a/lib/class-hook-reflector.php +++ b/lib/class-hook-reflector.php @@ -18,7 +18,7 @@ public function getName() { return $name->value; } - $printer = new \PhpParser\PrettyPrinter\Standard(); + $printer = new Pretty_Printer(); return $this->cleanupName( $printer->prettyPrintExpr( $this->node->args[0]->value ) ); } diff --git a/lib/class-pretty-printer.php b/lib/class-pretty-printer.php index 7cfd0c5..67e6b9d 100644 --- a/lib/class-pretty-printer.php +++ b/lib/class-pretty-printer.php @@ -5,7 +5,24 @@ /** * Extends default printer for arguments. */ -class Pretty_Printer extends \PhpParser\PrettyPrinter\Standard { +class Pretty_Printer extends \phpDocumentor\Reflection\PrettyPrinter { + /** + * Print names as they appeared before PHP-Parser's name resolution. + * + * PHP-Parser represents resolved global names as fully-qualified names. The + * leading namespace separator is useful in an AST, but adding it to exported + * source expressions changes the established JSON output. + * + * @param \PhpParser\Node\Name\FullyQualified $node Fully-qualified name. + * + * @return string Printed name. + */ + protected function pName_FullyQualified( \PhpParser\Node\Name\FullyQualified $node ): string { + $name = $node->toString(); + + return false === strpos( $name, '\\' ) ? $name : '\\' . $name; + } + /** * Pretty prints an argument. * diff --git a/lib/runner.php b/lib/runner.php index 0ce5d16..19b1d2e 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -90,7 +90,7 @@ function parse_files( $files, $root ) { $out['constants'][] = array( 'name' => $constant->getShortName(), 'line' => $constant->getLineNumber(), - 'value' => $constant->getValue(), + 'value' => export_expression( $constant->getNode()->value ), ); } @@ -186,6 +186,48 @@ function strip_global_namespace_prefixes( array $names ) { return $names; } +/** + * Export an expression without PHP-Parser's synthetic global namespace prefixes. + * + * @param null|\PhpParser\Node\Expr $expression Expression to export. + * + * @return null|string + */ +function export_expression( $expression ) { + if ( null === $expression ) { + return null; + } + + static $printer = null; + + if ( null === $printer ) { + $printer = new Pretty_Printer(); + } + + return $printer->prettyPrintExpr( $expression ); +} + +/** + * Remove synthetic global namespace prefixes from inline DocBlock references. + * + * @param string $text Formatted DocBlock text. + * + * @return string + */ +function strip_global_namespace_prefixes_from_inline_references( $text ) { + return preg_replace_callback( + '~{@(?:link|see)\s+([^}\s]+)~', + static function( $matches ) { + return str_replace( + $matches[1], + strip_global_namespace_prefix( $matches[1] ), + $matches[0] + ); + }, + $text + ); +} + /** * Fixes newline handling in parsed text. * @@ -514,8 +556,12 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), } $output = array( - 'description' => preg_replace( '/[\n\r]+/', ' ', $short_description ), - 'long_description' => format_long_description( strip_docblock_code_snippet_fences( $raw_long_description, $fences ) ), + 'description' => strip_global_namespace_prefixes_from_inline_references( + preg_replace( '/[\n\r]+/', ' ', $short_description ) + ), + 'long_description' => strip_global_namespace_prefixes_from_inline_references( + format_long_description( strip_docblock_code_snippet_fences( $raw_long_description, $fences ) ) + ), 'tags' => array(), ); @@ -535,19 +581,21 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), $tag_data = array( 'name' => $tag->getName(), - 'content' => preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) ), + 'content' => strip_global_namespace_prefixes_from_inline_references( + preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) ) + ), ); if ( method_exists( $tag, 'getTypes' ) ) { $tag_data['types'] = strip_global_namespace_prefixes( $tag->getTypes() ); } if ( method_exists( $tag, 'getLink' ) ) { - $tag_data['link'] = $tag->getLink(); + $tag_data['link'] = strip_global_namespace_prefix( $tag->getLink() ); } if ( method_exists( $tag, 'getVariableName' ) ) { $tag_data['variable'] = $tag->getVariableName(); } if ( method_exists( $tag, 'getReference' ) ) { - $tag_data['refers'] = $tag->getReference(); + $tag_data['refers'] = strip_global_namespace_prefix( $tag->getReference() ); } if ( method_exists( $tag, 'getVersion' ) ) { // Version string. @@ -557,7 +605,9 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), } // Description string. if ( method_exists( $tag, 'getDescription' ) ) { - $description = preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) ); + $description = strip_global_namespace_prefixes_from_inline_references( + preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) ) + ); if ( ! empty( $description ) ) { $tag_data['description'] = $description; } @@ -705,7 +755,7 @@ function export_arguments( array $arguments ) { foreach ( $arguments as $argument ) { $output[] = array( 'name' => $argument->getName(), - 'default' => $argument->getDefault(), + 'default' => export_expression( $argument->getNode()->default ), 'type' => strip_global_namespace_prefix( $argument->getType() ), ); } @@ -728,7 +778,7 @@ function export_properties( array $properties, array $inherited_setup_blueprints 'name' => $property->getName(), 'line' => $property->getLineNumber(), 'end_line' => $property->getNode()->getAttribute( 'endLine' ), - 'default' => $property->getDefault(), + 'default' => export_expression( $property->getNode()->default ), // 'final' => $property->isFinal(), 'static' => $property->isStatic(), 'visibility' => $property->getVisibility(), diff --git a/tests/phpunit/tests/export/global-names.inc b/tests/phpunit/tests/export/global-names.inc index 3dd9998..54bd2f4 100644 --- a/tests/phpunit/tests/export/global-names.inc +++ b/tests/phpunit/tests/export/global-names.inc @@ -5,10 +5,30 @@ use \Global_Alias_Source as Global_Alias; /** * Documents a global type. * + * Calls {@see Global_Doc_Function()} while preserving \xC0 as documentation. + * * @param Global_Doc_Type $value Value. */ -function documented_global_type( \Global_Parameter $value ) {} +function documented_global_type( \Global_Parameter $value, $enabled = true, $nothing = null, $mode = GLOBAL_MODE, $escape = '\xC0', $namespaced = \Vendor\GLOBAL_MODE ) {} class Global_Child extends \Global_Parent implements \Global_Interface {} \Global_Class::global_method(); + +const GLOBAL_CONST = GLOBAL_VALUE; + +define( 'GLOBAL_DEFINED_CONST', global_default( GLOBAL_VALUE ) ); +define( 'NAMESPACED_DEFINED_CONST', \Vendor\global_default( \Vendor\GLOBAL_VALUE ) ); + +class Global_Defaults { + public $enabled = false; + public $mode = GLOBAL_MODE; + + public function create() { + return ( new \Global_Class() )->global_method(); + } + + public function create_namespaced() { + return ( new \Vendor\Global_Class() )->global_method(); + } +} diff --git a/tests/phpunit/tests/export/global-names.php b/tests/phpunit/tests/export/global-names.php index 7972a8b..7c90476 100644 --- a/tests/phpunit/tests/export/global-names.php +++ b/tests/phpunit/tests/export/global-names.php @@ -22,6 +22,19 @@ public function test_function_metadata() { $function['aliases'] ); $this->assertEquals( 'Global_Parameter', $function['arguments'][0]['type'] ); + $this->assertSame( 'true', $function['arguments'][1]['default'] ); + $this->assertSame( 'null', $function['arguments'][2]['default'] ); + $this->assertSame( 'GLOBAL_MODE', $function['arguments'][3]['default'] ); + $this->assertSame( "'\\xC0'", $function['arguments'][4]['default'] ); + $this->assertSame( '\\Vendor\\GLOBAL_MODE', $function['arguments'][5]['default'] ); + $this->assertStringContainsString( + '{@see Global_Doc_Function()}', + $function['doc']['long_description'] + ); + $this->assertStringContainsString( + '\\xC0 as documentation', + $function['doc']['long_description'] + ); $this->assertEquals( array( 'Global_Doc_Type' ), $function['doc']['tags'][0]['types'] @@ -38,6 +51,37 @@ public function test_class_metadata() { $this->assertEquals( array( 'Global_Interface' ), $class['implements'] ); } + /** + * Test expression metadata. + */ + public function test_expression_metadata() { + $this->assertSame( 'GLOBAL_VALUE', $this->export_data['constants'][0]['value'] ); + $this->assertSame( + 'global_default(GLOBAL_VALUE)', + $this->export_data['constants'][1]['value'] + ); + $this->assertSame( + '\\Vendor\\global_default(\\Vendor\\GLOBAL_VALUE)', + $this->export_data['constants'][2]['value'] + ); + + $class = $this->export_data['classes'][1]; + $this->assertSame( 'false', $class['properties'][0]['default'] ); + $this->assertSame( 'GLOBAL_MODE', $class['properties'][1]['default'] ); + + $method = $class['methods'][0]; + $this->assertSame( + 'new Global_Class()', + $method['uses']['methods'][0]['class'] + ); + + $method = $class['methods'][1]; + $this->assertSame( + 'new \\Vendor\\Global_Class()', + $method['uses']['methods'][0]['class'] + ); + } + /** * Test method-use metadata. */ @@ -45,8 +89,8 @@ public function test_method_use_metadata() { $this->assertFileUsesMethod( array( 'name' => 'global_method', - 'line' => 14, - 'end_line' => 14, + 'line' => 16, + 'end_line' => 16, 'class' => 'Global_Class', 'static' => true, ) From f09a8a9282133ad3dfaa5f7c440c1bb012069f64 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 10 Aug 2026 15:28:15 +0400 Subject: [PATCH 08/13] Export hook names as spelled in the source `Hook_Reflector::getName()` short-circuited on `Scalar\String_` nodes and returned the interpreted string value. Escape sequences were therefore resolved, so `do_action( "\x09tab" )` exported a literal tab and `do_action( "\xC0 bad" )` exported a raw 0xC0 byte. That byte is not valid UTF-8, `json_encode()` returns `false` for it, and `wp parser export` silently produced no JSON at all for the whole run. Route string nodes through `Pretty_Printer` like every other expression. The printer returns php-parser's `rawValue` attribute, which is the source-verbatim spelling, and `cleanupName()` strips the quotes. Also check `json_encode()` for failure in `Command::_get_phpdoc_data()` and fail loudly with `json_last_error_msg()` instead of writing an empty file. --- lib/class-command.php | 9 ++++++++- lib/class-hook-reflector.php | 11 ++++++----- tests/phpunit/tests/export/hooks.inc | 1 + tests/phpunit/tests/export/hooks.php | 23 ++++++++++++++++++++++- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/lib/class-command.php b/lib/class-command.php index ee93eb4..4d9ca02 100644 --- a/lib/class-command.php +++ b/lib/class-command.php @@ -152,7 +152,14 @@ protected function _get_phpdoc_data( $path, $format = 'json' ) { $output = parse_files( $files, $path ); if ( 'json' == $format ) { - return json_encode( $output, JSON_PRETTY_PRINT ); + $json = json_encode( $output, JSON_PRETTY_PRINT ); + + if ( false === $json ) { + WP_CLI::error( sprintf( 'Problem encoding the data from %1$s as JSON: %2$s', $path, json_last_error_msg() ) ); + exit; + } + + return $json; } return $output; diff --git a/lib/class-hook-reflector.php b/lib/class-hook-reflector.php index be10164..f159cdd 100644 --- a/lib/class-hook-reflector.php +++ b/lib/class-hook-reflector.php @@ -10,14 +10,15 @@ class Hook_Reflector extends BaseReflector { /** + * Get the hook name as it is spelled in the source. + * + * The name is printed from the source expression instead of read from the + * interpreted string value. Interpreting escape sequences may produce bytes + * that are not valid UTF-8, which cannot be encoded as JSON. + * * @return string */ public function getName() { - $name = $this->node->args[0]->value; - if ( $name instanceof \PhpParser\Node\Scalar\String_ ) { - return $name->value; - } - $printer = new Pretty_Printer(); return $this->cleanupName( $printer->prettyPrintExpr( $this->node->args[0]->value ) ); } diff --git a/tests/phpunit/tests/export/hooks.inc b/tests/phpunit/tests/export/hooks.inc index 88a3736..9b581f9 100644 --- a/tests/phpunit/tests/export/hooks.inc +++ b/tests/phpunit/tests/export/hooks.inc @@ -9,3 +9,4 @@ apply_filters( 'plain_filter', $variable, $filter_context ); do_action( '\xC0 hook' ); do_action( "\x09tab" ); do_action( '\x09tab' ); +do_action( "\xC0 bad" ); diff --git a/tests/phpunit/tests/export/hooks.php b/tests/phpunit/tests/export/hooks.php index 0e9fe29..07a0a47 100644 --- a/tests/phpunit/tests/export/hooks.php +++ b/tests/phpunit/tests/export/hooks.php @@ -51,11 +51,32 @@ public function test_hook_names_standardized() { ); $this->assertFileContainsHook( - array( 'name' => "\ttab", 'line' => 10 ) + array( 'name' => '\\x09tab', 'line' => 10 ) ); $this->assertFileContainsHook( array( 'name' => '\\x09tab', 'line' => 11 ) ); } + + /** + * Test that hook names keep escapes that are invalid UTF-8 once interpreted. + */ + public function test_hook_names_keep_invalid_utf8_escapes() { + + $this->assertFileContainsHook( + array( 'name' => '\\xC0 bad', 'line' => 12 ) + ); + } + + /** + * Test that the exported data can be encoded as JSON. + */ + public function test_export_data_is_json_encodable() { + + $this->assertNotFalse( + json_encode( $this->export_data, JSON_PRETTY_PRINT ), + json_last_error_msg() + ); + } } From 354136800692d8e159e324d11abdc779237c598f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 10 Aug 2026 15:29:35 +0400 Subject: [PATCH 09/13] Print heredoc and nowdoc strings with their delimiters The pretty printer inherits an override that returns PHP-Parser's `rawValue` attribute so escape sequences are not interpreted. PHP-Parser sets that attribute to the body of a doc string, without the delimiters, so `apply_filters( 'f', <<getAttribute( 'kind' ); + + if ( + \PhpParser\Node\Scalar\String_::KIND_HEREDOC === $kind || + \PhpParser\Node\Scalar\String_::KIND_NOWDOC === $kind + ) { + return \PhpParser\PrettyPrinter\Standard::pScalar_String( $node ); + } + + return parent::pScalar_String( $node ); + } + /** * Pretty prints an argument. * diff --git a/tests/phpunit/tests/export/hooks.inc b/tests/phpunit/tests/export/hooks.inc index 9b581f9..10bfc9f 100644 --- a/tests/phpunit/tests/export/hooks.inc +++ b/tests/phpunit/tests/export/hooks.inc @@ -10,3 +10,9 @@ do_action( '\xC0 hook' ); do_action( "\x09tab" ); do_action( '\x09tab' ); do_action( "\xC0 bad" ); +apply_filters( 'heredoc_filter', <<assertFileContainsHook( + array( + 'type' => 'filter', + 'name' => 'heredoc_filter', + 'line' => 13, + 'arguments.0' => "<< '2', + ) + ); + + $this->assertFileContainsHook( + array( + 'type' => 'filter', + 'name' => 'nowdoc_filter', + 'line' => 16, + 'arguments.0' => "<<<'EOT'\nnowdoc \$body\nEOT", + 'arguments.1' => '2', + ) + ); + } + /** * Test that the exported data can be encoded as JSON. */ From 7dbfc5bc1c11fcdc171308978f11c65d9f603e1a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 10 Aug 2026 15:30:02 +0400 Subject: [PATCH 10/13] Document the namespaced-file limitation of printed global names `pName_FullyQualified()` prints single-segment fully-qualified names without the leading backslash regardless of namespace context, so inside a namespaced file the printed form denotes a namespaced symbol rather than the global one. This is an accepted limitation because the parser targets global-namespace WordPress core code. --- lib/class-pretty-printer.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/class-pretty-printer.php b/lib/class-pretty-printer.php index 3cf2d5e..b4ccf2f 100644 --- a/lib/class-pretty-printer.php +++ b/lib/class-pretty-printer.php @@ -13,6 +13,13 @@ class Pretty_Printer extends \phpDocumentor\Reflection\PrettyPrinter { * leading namespace separator is useful in an AST, but adding it to exported * source expressions changes the established JSON output. * + * Single-segment fully-qualified names are therefore printed without the + * leading backslash. Inside a namespaced file the printed form denotes a + * namespaced symbol rather than the global one, for example `\Foo::BAR` is + * printed as `Foo::BAR`, which in a namespaced file would resolve to + * `Vendor\Foo::BAR`. This is an accepted limitation because the parser + * targets global-namespace WordPress core code. + * * @param \PhpParser\Node\Name\FullyQualified $node Fully-qualified name. * * @return string Printed name. From 1d020ff573812af1cd204ea52460e1bf55c38036 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 10 Aug 2026 15:41:35 +0400 Subject: [PATCH 11/13] Preserve inline references appearing in code samples The global namespace prefixes are stripped from inline `{@link}` and `{@see}` references after the DocBlock text has been rendered, so the stripping also reached into rendered code regions and silently deleted the backslash an author had written in a verbatim code sample. Carve out `` regions before stripping, the same way `fix_newlines()` protects the newlines in those regions, so code samples are exported as they were written. --- lib/runner.php | 20 ++++++++- tests/phpunit/tests/export/global-names.inc | 14 ++++++ tests/phpunit/tests/export/global-names.php | 48 +++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/lib/runner.php b/lib/runner.php index 19b1d2e..9932423 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -210,12 +210,27 @@ function export_expression( $expression ) { /** * Remove synthetic global namespace prefixes from inline DocBlock references. * + * A special exception is made for text appearing in `` and `
` tags, as code
+ * samples are reproduced verbatim and any prefix appearing in them was written by hand.
+ *
  * @param string $text Formatted DocBlock text.
  *
  * @return string
  */
 function strip_global_namespace_prefixes_from_inline_references( $text ) {
-	return preg_replace_callback(
+	// Non-naturally occurring string to use as temporary replacement.
+	$replacement_string = '{{{{{}}}}}';
+
+	// Replace inline tag openings within 'code' and 'pre' tags with replacement string.
+	$text = preg_replace_callback(
+		"/(]*>)(.+)(?=<\/code>)/sU",
+		function ( $matches ) use ( $replacement_string ) {
+			return str_replace( '{@', $replacement_string, $matches[1] . $matches[2] );
+		},
+		$text
+	);
+
+	$text = preg_replace_callback(
 		'~{@(?:link|see)\s+([^}\s]+)~',
 		static function( $matches ) {
 			return str_replace(
@@ -226,6 +241,9 @@ static function( $matches ) {
 		},
 		$text
 	);
+
+	// Restore inline tag openings into code blocks.
+	return str_replace( $replacement_string, '{@', $text );
 }
 
 /**
diff --git a/tests/phpunit/tests/export/global-names.inc b/tests/phpunit/tests/export/global-names.inc
index 54bd2f4..b1e1d8e 100644
--- a/tests/phpunit/tests/export/global-names.inc
+++ b/tests/phpunit/tests/export/global-names.inc
@@ -32,3 +32,17 @@ class Global_Defaults {
 		return ( new \Vendor\Global_Class() )->global_method();
 	}
 }
+
+/**
+ * Documents inline references.
+ *
+ * Calls {@see \Global_Doc_Function()} and {@see \Vendor\Thing::m()} in prose.
+ *
+ * Spells `{@see \Global_Inline::method()}` in an inline code span.
+ *
+ * ```php
+ * // Renders {@see \Global_Widget::render()}.
+ * $widget->render();
+ * ```
+ */
+function documented_inline_references() {}
diff --git a/tests/phpunit/tests/export/global-names.php b/tests/phpunit/tests/export/global-names.php
index 7c90476..72cefea 100644
--- a/tests/phpunit/tests/export/global-names.php
+++ b/tests/phpunit/tests/export/global-names.php
@@ -41,6 +41,54 @@ public function test_function_metadata() {
 		);
 	}
 
+	/**
+	 * Test that prefixes are removed from inline references in prose.
+	 */
+	public function test_prefixed_inline_reference_metadata() {
+		$function = $this->export_data['functions'][1];
+
+		$this->assertStringContainsString(
+			'{@see Global_Doc_Function()}',
+			$function['doc']['long_description']
+		);
+	}
+
+	/**
+	 * Test that namespaced inline references keep their prefix.
+	 */
+	public function test_namespaced_inline_reference_metadata() {
+		$function = $this->export_data['functions'][1];
+
+		$this->assertStringContainsString(
+			'{@see \\Vendor\\Thing::m()}',
+			$function['doc']['long_description']
+		);
+	}
+
+	/**
+	 * Test that inline references in code samples are preserved.
+	 */
+	public function test_code_sample_inline_reference_metadata() {
+		$function = $this->export_data['functions'][1];
+
+		$this->assertStringContainsString(
+			'// Renders {@see \\Global_Widget::render()}.',
+			$function['doc']['long_description']
+		);
+	}
+
+	/**
+	 * Test that inline references in inline code spans are preserved.
+	 */
+	public function test_inline_code_span_inline_reference_metadata() {
+		$function = $this->export_data['functions'][1];
+
+		$this->assertStringContainsString(
+			'{@see \\Global_Inline::method()}',
+			$function['doc']['long_description']
+		);
+	}
+
 	/**
 	 * Test class metadata.
 	 */

From 460450cf0cce5347b10717eeeb68f7275ceb2695 Mon Sep 17 00:00:00 2001
From: Jon Surrell 
Date: Mon, 10 Aug 2026 20:08:49 +0400
Subject: [PATCH 12/13] Strip surrounding quotes from hook names containing a
 quote

The quote-stripping pattern required a body free of quote characters, so
a hook name that contained one, like `do_action( "it's" );`, was exported
with the quotes that surround it in the source.

Match the opening quote and require the same quote at the end, allowing
the body to hold the other quote character or an escaped copy of the
delimiter. Only that pair is stripped; the body keeps its source spelling,
so `do_action( 'it\'s' );` exports as `it\'s`. Concatenated expressions
still fall through to the dynamic-name handling below.
---
 lib/class-hook-reflector.php         |  6 ++++--
 tests/phpunit/tests/export/hooks.inc |  2 ++
 tests/phpunit/tests/export/hooks.php | 15 +++++++++++++++
 3 files changed, 21 insertions(+), 2 deletions(-)

diff --git a/lib/class-hook-reflector.php b/lib/class-hook-reflector.php
index f159cdd..29036e5 100644
--- a/lib/class-hook-reflector.php
+++ b/lib/class-hook-reflector.php
@@ -32,8 +32,10 @@ private function cleanupName( $name ) {
 		$matches = array();
 
 		// quotes on both ends of a string
-		if ( preg_match( '/^[\'"]([^\'"]*)[\'"]$/', $name, $matches ) ) {
-			return $matches[1];
+		// The quoted body may contain the other quote character, as in "it's",
+		// or an escaped copy of the quote that delimits it, as in 'it\'s'.
+		if ( preg_match( '/^([\'"])((?:(?!\1)[^\\\\]|\\\\.)*)\1$/s', $name, $matches ) ) {
+			return $matches[2];
 		}
 
 		// two concatenated things, last one of them a variable
diff --git a/tests/phpunit/tests/export/hooks.inc b/tests/phpunit/tests/export/hooks.inc
index 10bfc9f..6a8b466 100644
--- a/tests/phpunit/tests/export/hooks.inc
+++ b/tests/phpunit/tests/export/hooks.inc
@@ -16,3 +16,5 @@ EOT, 2 );
 apply_filters( 'nowdoc_filter', <<<'EOT'
 nowdoc $body
 EOT, 2 );
+do_action( "it's" );
+do_action( 'it\'s' );
diff --git a/tests/phpunit/tests/export/hooks.php b/tests/phpunit/tests/export/hooks.php
index 0fa4eb5..38d8624 100644
--- a/tests/phpunit/tests/export/hooks.php
+++ b/tests/phpunit/tests/export/hooks.php
@@ -95,6 +95,21 @@ public function test_hook_arguments_keep_doc_string_delimiters() {
 		);
 	}
 
+	/**
+	 * Test that hook names containing a quote character lose the quotes that
+	 * surround them in the source.
+	 */
+	public function test_hook_names_containing_quotes() {
+
+		$this->assertFileContainsHook(
+			array( 'name' => "it's", 'line' => 19 )
+		);
+
+		$this->assertFileContainsHook(
+			array( 'name' => "it\\'s", 'line' => 20 )
+		);
+	}
+
 	/**
 	 * Test that the exported data can be encoded as JSON.
 	 */

From 4a9d962bb06f5525d17130231350be8c60862efc Mon Sep 17 00:00:00 2001
From: Jon Surrell 
Date: Mon, 10 Aug 2026 19:37:55 +0400
Subject: [PATCH 13/13] Test the class mapping for known factory function
 receivers

`Method_Call_Reflector::_getClassMapping()` maps a handful of WordPress factory
functions to the class they return, so that `get_current_screen()->add_help_tab()`
is exported as a use of `WP_Screen::add_help_tab()`. The lookup never matched
before this branch, because the printed receiver carried a leading backslash
that the mapping keys do not have. Pin the restored behavior with a test.
---
 .../tests/export/uses/class-mapping.inc       |  5 ++++
 .../tests/export/uses/class-mapping.php       | 30 +++++++++++++++++++
 2 files changed, 35 insertions(+)
 create mode 100644 tests/phpunit/tests/export/uses/class-mapping.inc
 create mode 100644 tests/phpunit/tests/export/uses/class-mapping.php

diff --git a/tests/phpunit/tests/export/uses/class-mapping.inc b/tests/phpunit/tests/export/uses/class-mapping.inc
new file mode 100644
index 0000000..08d422c
--- /dev/null
+++ b/tests/phpunit/tests/export/uses/class-mapping.inc
@@ -0,0 +1,5 @@
+add_help_tab( array() );
+}
diff --git a/tests/phpunit/tests/export/uses/class-mapping.php b/tests/phpunit/tests/export/uses/class-mapping.php
new file mode 100644
index 0000000..e306117
--- /dev/null
+++ b/tests/phpunit/tests/export/uses/class-mapping.php
@@ -0,0 +1,30 @@
+assertFunctionUsesMethod(
+			'show_help'
+			, array(
+				'name'     => 'add_help_tab',
+				'line'     => 4,
+				'end_line' => 4,
+				'class'    => 'WP_Screen',
+				'static'   => false,
+			)
+		);
+	}
+}