Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions prep-diff.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
* diff -u before.norm.json after.norm.json
*/

/*
* The exporter's own normalization rules are reused so that this script cannot
* hide a difference in them. The file only declares namespaced functions, so it
* is safe to load without the Composer autoloader.
*/
require_once __DIR__ . '/lib/runner.php';

/**
* Checks if an array is a JSON list.
*
Expand Down Expand Up @@ -75,10 +82,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;
}
Expand All @@ -91,14 +99,38 @@ function wp_parser_prep_diff_normalize_scalar( $value, $key ) {
return $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
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\strip_global_namespace_prefix( $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
);

return null === $without_global_namespace ? $value : $without_global_namespace;
$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\strip_global_namespace_prefix( $value ) : $value;
}

/**
Expand Down Expand Up @@ -222,7 +254,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 ) ) {
Expand Down
273 changes: 273 additions & 0 deletions tests/phpunit/tests/prep-diff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
<?php

/**
* A test case for the diff normalization script.
*/

namespace WP_Parser\Tests;

/**
* Test that generated JSON is normalized for stable diffs.
*/
class Prep_Diff_Test extends \WP_UnitTestCase {

/**
* Normalizes JSON by running prep-diff.php as a standalone script.
*
* @param string $json JSON to normalize.
*
* @return string Normalized JSON.
*/
protected function normalize( $json ) {

$script = dirname( __DIR__, 3 ) . '/prep-diff.php';

$descriptor_spec = array(
0 => array( 'pipe', 'r' ),
1 => array( 'pipe', 'w' ),
2 => array( 'pipe', 'w' ),
);

$process = proc_open(
escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( $script ),
$descriptor_spec,
$pipes
);

$this->assertIsResource( $process, 'Unable to start prep-diff.php.' );

fwrite( $pipes[0], $json );
fclose( $pipes[0] );

$output = stream_get_contents( $pipes[1] );
$error = stream_get_contents( $pipes[2] );

fclose( $pipes[1] );
fclose( $pipes[2] );

$status = proc_close( $process );

$this->assertSame( 0, $status, trim( $error ) );

return $output;
}

/**
* Returns generated JSON for a build.
*
* @return string JSON with parser output in source order.
*/
protected function get_json() {

return json_encode(
array(
array(
'root' => '/tmp/build-a',
'path' => 'beta.php',
'call_graph' => array(
array( 'name' => 'zeta', 'line' => 9, 'end_line' => 9 ),
array( 'name' => 'alpha', 'line' => 3, 'end_line' => 3 ),
),
'functions' => array(
array(
'uses' => array(
'functions' => array(
array( 'name' => 'zeta', 'line' => 9, 'end_line' => 9 ),
array( 'name' => 'alpha', 'line' => 3, 'end_line' => 3 ),
),
),
'line' => 20,
'name' => 'beta',
'namespace' => 'global',
'arguments' => array(
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 {@see \\alpha()}; preserves \\xC0.',
),
),
),
),
array(
'path' => 'alpha.php',
'root' => '/tmp/build-a',
),
)
);
}

/**
* Returns generated JSON for an equivalent build with shuffled output.
*
* @return string JSON with the same content in a different order.
*/
protected function get_shuffled_json() {

return json_encode(
array(
array(
'root' => '/tmp/build-b',
'path' => 'alpha.php',
),
array(
'path' => 'beta.php',
'root' => '/tmp/build-b',
'call_graph' => array(
array( 'end_line' => 90, 'line' => 90, 'name' => 'zeta' ),
array( 'end_line' => 30, 'line' => 30, 'name' => 'alpha' ),
),
'functions' => array(
array(
'namespace' => 'global',
'name' => 'beta',
'line' => 98,
'doc' => array(
'description' => 'Calls {@see alpha()}; preserves \\xC0.',
'long_description' => '',
'tags' => array(
array( 'name' => 'since', 'content' => '1.0.0' ),
array( 'name' => 'param', 'content' => 'First.', 'variable' => '$first' ),
),
),
'arguments' => array(
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' ),
array( 'end_line' => 90, 'line' => 90, 'name' => 'zeta' ),
),
),
),
),
),
)
);
}

/**
* Test that equivalent output with incidental differences normalizes identically.
*/
public function test_equivalent_output_normalizes_identically() {

$this->assertSame(
$this->normalize( $this->get_json() ),
$this->normalize( $this->get_shuffled_json() )
);
}

/**
* Test that unordered parser collections are sorted.
*/
public function test_unordered_collections_are_sorted() {

$decoded = json_decode( $this->normalize( $this->get_json() ), true );

$this->assertSame( 'alpha.php', $decoded[0]['path'] );
$this->assertSame(
array( 'alpha', 'zeta' ),
array_column( $decoded[1]['call_graph'], 'name' )
);
$this->assertSame(
array( 'alpha', 'zeta' ),
array_column( $decoded[1]['functions'][0]['uses']['functions'], 'name' )
);
$this->assertSame(
array( 'arguments', 'doc', 'hooks', 'line', 'name', 'namespace', 'uses' ),
array_keys( $decoded[1]['functions'][0] )
);
}

/**
* Test that ordered documentation data is left in source order.
*/
public function test_documentation_order_is_preserved() {

$decoded = json_decode( $this->normalize( $this->get_json() ), true );

$this->assertSame(
array( '$first', '$second' ),
array_column( $decoded[1]['functions'][0]['arguments'], 'name' )
);
$this->assertSame(
array( 'since', 'param' ),
array_column( $decoded[1]['functions'][0]['doc']['tags'], 'name' )
);
}

/**
* Test that literal escape sequences are preserved.
*/
public function test_literal_escape_sequences_are_preserved() {

$decoded = json_decode( $this->normalize( $this->get_json() ), true );

$this->assertSame(
'Calls {@see alpha()}; preserves \\xC0.',
$decoded[1]['functions'][0]['doc']['description']
);
$this->assertSame( '\\x09tab', $decoded[1]['functions'][0]['hooks'][0]['name'] );
}

/**
* Test that printed expressions only lose an anchored global prefix.
*
* Printed expressions are normalized with the same rule the exporter applies,
* so that global names appearing inside string literals are left alone.
*
* @dataProvider data_printed_expressions
*
* @param string $key Key holding the printed expression.
* @param string $value Printed expression.
* @param string $expected Expected normalized expression.
*/
public function test_printed_expressions_normalize_anchored_prefixes( $key, $value, $expected ) {

$decoded = json_decode( $this->normalize( json_encode( array( $key => $value ) ) ), true );

$this->assertSame( $expected, $decoded[ $key ] );
}

/**
* Data provider for printed expressions.
*
* @return array[] Key, printed expression, and expected normalized expression.
*/
public function data_printed_expressions() {

return array(
'string literal containing a global name' => array( 'default', "'see \\Foo bar'", "'see \\Foo bar'" ),
'string literal in a constant value' => array( 'value', "'see \\Foo bar'", "'see \\Foo bar'" ),
'printed global name' => array( 'default', '\\Foo', 'Foo' ),
'printed namespaced name' => array( 'default', '\\Vendor\\Thing', '\\Vendor\\Thing' ),
);
}

/**
* Test that real content changes remain visible.
*/
public function test_content_changes_remain_visible() {

$changed = json_decode( $this->get_shuffled_json(), true );

$changed[1]['functions'][0]['name'] = 'changed';

$this->assertNotSame(
$this->normalize( $this->get_json() ),
$this->normalize( json_encode( $changed ) )
);
}
}
Loading