diff --git a/lib/class-hook-reflector.php b/lib/class-hook-reflector.php index 6b979b93..be101644 100644 --- a/lib/class-hook-reflector.php +++ b/lib/class-hook-reflector.php @@ -13,7 +13,12 @@ class Hook_Reflector extends BaseReflector { * @return string */ public function getName() { - $printer = new \PhpParser\PrettyPrinter\Standard(); + $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/lib/class-pretty-printer.php b/lib/class-pretty-printer.php index 7cfd0c55..67e6b9d0 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 4250b23a..3ff31fa8 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -81,7 +81,7 @@ function parse_files( $files, $root ) { $out['constants'][] = array( 'name' => $constant->getShortName(), 'line' => $constant->getLineNumber(), - 'value' => $constant->getValue(), + 'value' => export_expression( $constant->getNode()->value ), ); } @@ -93,7 +93,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() ), @@ -120,8 +120,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() ), 'methods' => export_methods( $class->getMethods() ), 'doc' => export_docblock( $class ), @@ -137,33 +137,83 @@ 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 - * by removing a leading reverse solidus (\) when no other reverse - * solidus appears before the end of a sequence of PHP identifier - * characters. - */ - array_walk_recursive( - $output, - 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', - $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; +} + +/** + * 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 + ); } /** @@ -237,27 +287,33 @@ function export_docblock( $element ) { } $output = array( - 'description' => preg_replace( '/[\n\r]+/', ' ', $docblock->getShortDescription() ), - 'long_description' => fix_newlines( $docblock->getLongDescription()->getFormattedContents() ), + 'description' => strip_global_namespace_prefixes_from_inline_references( + preg_replace( '/[\n\r]+/', ' ', $docblock->getShortDescription() ) + ), + 'long_description' => strip_global_namespace_prefixes_from_inline_references( + fix_newlines( $docblock->getLongDescription()->getFormattedContents() ) + ), 'tags' => array(), ); foreach ( $docblock->getTags() as $tag ) { $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'] = $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. @@ -267,7 +323,9 @@ function export_docblock( $element ) { } // 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; } @@ -312,8 +370,8 @@ function export_arguments( array $arguments ) { foreach ( $arguments as $argument ) { $output[] = array( 'name' => $argument->getName(), - 'default' => $argument->getDefault(), - 'type' => $argument->getType(), + 'default' => export_expression( $argument->getNode()->default ), + 'type' => strip_global_namespace_prefix( $argument->getType() ), ); } @@ -333,7 +391,7 @@ function export_properties( array $properties ) { '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(), @@ -357,7 +415,7 @@ function export_methods( array $methods ) { $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(), @@ -408,7 +466,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' ), @@ -417,6 +475,7 @@ function export_uses( array $uses ) { default: case 'functions': + $name = strip_global_namespace_prefix( $name ); $out[ $type ][] = array( 'name' => $name, 'line' => $element->getLineNumber(), diff --git a/prep-diff.php b/prep-diff.php index 42e69c8c..be02fbc0 100644 --- a/prep-diff.php +++ b/prep-diff.php @@ -75,10 +75,11 @@ function wp_parser_prep_diff_is_simple_name_record_list( array $list ) { * Normalizes scalar values that should not affect output comparisons. * * @param mixed $value Scalar value. + * @param array $path Current JSON path. * @param string|null $key Parent object key. * @return mixed Normalized value. */ -function wp_parser_prep_diff_normalize_scalar( $value, $key ) { +function wp_parser_prep_diff_normalize_scalar( $value, array $path, $key ) { if ( in_array( $key, array( 'line', 'end_line', 'startLine', 'endLine' ), true ) ) { return 0; } @@ -91,6 +92,47 @@ function wp_parser_prep_diff_normalize_scalar( $value, $key ) { return $value; } + if ( in_array( $key, array( 'content', 'description', 'long_description' ), true ) ) { + return preg_replace_callback( + '~{@(?:link|see)\s+([^}\s]+)~', + static function( $matches ) { + return str_replace( + $matches[1], + wp_parser_prep_diff_normalize_global_names( $matches[1] ), + $matches[0] + ); + }, + $value + ); + } + + $parent_key = 1 < count( $path ) ? $path[ count( $path ) - 2 ] : null; + $normalize = in_array( + $key, + array( 'class', 'default', 'extends', 'link', 'refers', 'type', 'value' ), + true + ); + + $normalize = $normalize + || in_array( $parent_key, array( 'aliases', 'implements', 'types' ), true ) + || ( + 'name' === $key + && ( + wp_parser_prep_diff_path_ends_with( $path, array( 'uses', 'functions', '[]', 'name' ) ) + || wp_parser_prep_diff_path_ends_with( $path, array( 'uses', 'methods', '[]', 'name' ) ) + ) + ); + + return $normalize ? wp_parser_prep_diff_normalize_global_names( $value ) : $value; +} + +/** + * Remove synthetic global namespace prefixes from semantic names. + * + * @param string $value Name or expression to normalize. + * @return string Normalized value. + */ +function wp_parser_prep_diff_normalize_global_names( $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}]|->|$)~', @@ -222,7 +264,7 @@ function wp_parser_prep_diff_should_sort_list( array $path, array $list ) { */ function wp_parser_prep_diff_normalize( $value, array $path = array(), $key = null ) { if ( ! is_array( $value ) ) { - return wp_parser_prep_diff_normalize_scalar( $value, $key ); + return wp_parser_prep_diff_normalize_scalar( $value, $path, $key ); } if ( wp_parser_prep_diff_is_list( $value ) ) { diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index 05686f51..5e44ba30 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -28,6 +28,21 @@ 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() {} + +/** + * \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 0d49b15d..3a2aa66b 100644 --- a/tests/phpunit/tests/export/docblocks.php +++ b/tests/phpunit/tests/export/docblocks.php @@ -108,6 +108,31 @@ 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 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 00000000..54bd2f4b
--- /dev/null
+++ b/tests/phpunit/tests/export/global-names.inc
@@ -0,0 +1,34 @@
+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
new file mode 100644
index 00000000..7c90476f
--- /dev/null
+++ b/tests/phpunit/tests/export/global-names.php
@@ -0,0 +1,99 @@
+export_data['functions'][0];
+
+ $this->assertEquals(
+ array( 'Global_Alias' => 'Global_Alias_Source' ),
+ $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']
+ );
+ }
+
+ /**
+ * 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 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.
+ */
+ public function test_method_use_metadata() {
+ $this->assertFileUsesMethod(
+ array(
+ 'name' => 'global_method',
+ 'line' => 16,
+ 'end_line' => 16,
+ 'class' => 'Global_Class',
+ 'static' => true,
+ )
+ );
+ }
+}
diff --git a/tests/phpunit/tests/export/hooks.inc b/tests/phpunit/tests/export/hooks.inc
index 54cf8f81..88a3736f 100644
--- a/tests/phpunit/tests/export/hooks.inc
+++ b/tests/phpunit/tests/export/hooks.inc
@@ -6,3 +6,6 @@ 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' );
+do_action( "\x09tab" );
+do_action( '\x09tab' );
diff --git a/tests/phpunit/tests/export/hooks.php b/tests/phpunit/tests/export/hooks.php
index cf9d608c..0e9fe299 100644
--- a/tests/phpunit/tests/export/hooks.php
+++ b/tests/phpunit/tests/export/hooks.php
@@ -45,5 +45,17 @@ public function test_hook_names_standardized() {
'arguments.1' => '$filter_context'
)
);
+
+ $this->assertFileContainsHook(
+ array( 'name' => '\\xC0 hook', 'line' => 9 )
+ );
+
+ $this->assertFileContainsHook(
+ array( 'name' => "\ttab", 'line' => 10 )
+ );
+
+ $this->assertFileContainsHook(
+ array( 'name' => '\\x09tab', 'line' => 11 )
+ );
}
}
diff --git a/tests/prep-diff-test.php b/tests/prep-diff-test.php
index e4860e5f..5e968a3d 100644
--- a/tests/prep-diff-test.php
+++ b/tests/prep-diff-test.php
@@ -65,16 +65,19 @@ function assert_true( $condition, $message ) {
'name' => 'beta',
'namespace' => 'global',
'arguments' => array(
- array( 'name' => '$first', 'type' => '' ),
+ array( 'name' => '$first', 'type' => '\\Global_Type', 'default' => '\\false' ),
array( 'name' => '$second', 'type' => '' ),
),
+ 'hooks' => array(
+ array( 'name' => '\\x09tab', 'type' => 'action', 'line' => 10, 'end_line' => 10 ),
+ ),
'doc' => array(
'tags' => array(
array( 'name' => 'since', 'content' => '1.0.0' ),
array( 'name' => 'param', 'content' => 'First.', 'variable' => '$first' ),
),
'long_description' => '',
- 'description' => 'Calls \\alpha().',
+ 'description' => 'Calls {@see \\alpha()}; preserves \\xC0.',
),
),
),
@@ -105,7 +108,7 @@ function assert_true( $condition, $message ) {
'name' => 'beta',
'line' => 98,
'doc' => array(
- 'description' => 'Calls alpha().',
+ 'description' => 'Calls {@see alpha()}; preserves \\xC0.',
'long_description' => '',
'tags' => array(
array( 'name' => 'since', 'content' => '1.0.0' ),
@@ -113,9 +116,12 @@ function assert_true( $condition, $message ) {
),
),
'arguments' => array(
- array( 'type' => '', 'name' => '$first' ),
+ array( 'default' => 'false', 'type' => 'Global_Type', 'name' => '$first' ),
array( 'type' => '', 'name' => '$second' ),
),
+ 'hooks' => array(
+ array( 'end_line' => 100, 'line' => 100, 'type' => 'action', 'name' => '\\x09tab' ),
+ ),
'uses' => array(
'functions' => array(
array( 'end_line' => 30, 'line' => 30, 'name' => 'alpha' ),
@@ -140,7 +146,12 @@ function assert_true( $condition, $message ) {
assert_true( array( '$first', '$second' ) === array_column( $decoded[1]['functions'][0]['arguments'], 'name' ), 'Function argument order should be preserved.' );
assert_true( array( 'since', 'param' ) === array_column( $decoded[1]['functions'][0]['doc']['tags'], 'name' ), 'Doc tag order should be preserved.' );
assert_true( array( 'alpha', 'zeta' ) === array_column( $decoded[1]['functions'][0]['uses']['functions'], 'name' ), 'Function uses should sort by name.' );
-assert_true( array_keys( $decoded[1]['functions'][0] ) === array( 'arguments', 'doc', 'line', 'name', 'namespace', 'uses' ), 'Object keys should be sorted.' );
+assert_true( array_keys( $decoded[1]['functions'][0] ) === array( 'arguments', 'doc', 'hooks', 'line', 'name', 'namespace', 'uses' ), 'Object keys should be sorted.' );
+assert_true(
+ 'Calls {@see alpha()}; preserves \\xC0.' === $decoded[1]['functions'][0]['doc']['description'],
+ 'Documentation escape sequences should be preserved.'
+);
+assert_true( '\\x09tab' === $decoded[1]['functions'][0]['hooks'][0]['name'], 'Hook names should be preserved.' );
$changed = json_decode( $b, true );
$changed[1]['functions'][0]['name'] = 'changed';